How to Set Up cert-manager with Let's Encrypt and Fix Errors (2026)
This guide is for anyone running Kubernetes who wants public TLS certificates from Let's Encrypt issued and renewed by cert-manager, and who wants to know exactly where to look when a Certificate sits at READY False. By the end you will have cert-manager installed, staging and production ClusterIssuers for HTTP-01 and DNS-01, a working certificate on a Gateway (or Ingress), Prometheus alerts for failed renewals, and a repeatable way to debug the resource chain. Commands are written against cert-manager v1.21.2 (released 11 September 2026), cmctl v2.6.1, Gateway API v1.6.3 and Helm 3; I rendered the chart locally with these values but did not run them against your cluster, so read each step before you paste it. You need kubectl access with cluster-admin, Helm, and a domain whose DNS you control.
Why this matters more in 2026: public certificate lifetimes are falling to 200 days now and 47 days by 2029, and Let's Encrypt is moving to 45 days. The background is in my sibling post on TLS certificate lifetimes and the 47-day schedule. Short version: renewal will happen constantly, so a broken issuer stops being a yearly annoyance.
Step 1: Install cert-manager with Helm
The official Helm instructions recommend the OCI chart, which is published as soon as a release is cut. crds.enabled=true lets Helm manage the CRDs. If you use Gateway API, install its CRDs first, then enable cert-manager's Gateway support with config.gatewayAPI.enabled (the older enableGatewayAPI field was deprecated in v1.21).
# 1. Gateway API CRDs (skip if your Gateway implementation already installed them)
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.3/standard-install.yaml
# 2. cert-manager v1.21.2 from the OCI registry
helm install cert-manager oci://quay.io/jetstack/charts/cert-manager \
--version v1.21.2 \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true \
--set config.gatewayAPI.enabled=true \
--set featureGates=ACMEUseARI=true # optional, experimental: see Step 8
# 3. Verify the API is ready (brew install cmctl, or go install github.com/cert-manager/cmctl/v2@latest)
cmctl check api --wait=2m
# The cert-manager API is ready
If you installed the Gateway API CRDs after cert-manager, restart the controller so it notices them: kubectl rollout restart deployment cert-manager -n cert-manager.
Step 2: Create staging and production ClusterIssuers
Always start with the Let's Encrypt staging server. Its rate limits are generous and its certificates are untrusted, which is exactly what you want while you are still breaking things. The profile field (cert-manager v1.18 and later) selects an ACME profile; omit it to get Let's Encrypt's classic default (90 days today), or set tlsserver to get 45-day certificates now rather than when the default changes in 2027.
This example uses HTTP-01 through a Gateway named public in the gateway namespace. The HTTP-01 docs note that the Gateway must already exist and have a listener on port 80.
# clusterissuers.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: ops@example.com # account contact; must be a valid address
profile: tlsserver # optional; requires cert-manager >= v1.18
privateKeySecretRef:
name: letsencrypt-staging-account # ACME account key, created for you
solvers:
- http01:
gatewayHTTPRoute:
parentRefs:
- name: public
namespace: gateway
kind: Gateway
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ops@example.com
profile: tlsserver
privateKeySecretRef:
name: letsencrypt-prod-account
solvers:
- http01:
gatewayHTTPRoute:
parentRefs:
- name: public
namespace: gateway
kind: Gateway
Still on Ingress? Replace the solver with http01: { ingress: { ingressClassName: <your-class> } }. Keep in mind that the community Ingress NGINX controller was retired in March 2026 and gets no more security fixes; see migrating from Ingress NGINX to Gateway API.
kubectl apply -f clusterissuers.yaml
kubectl get clusterissuer
# NAME READY AGE
# letsencrypt-prod True 10s
# letsencrypt-staging True 10s
READY True means cert-manager registered the ACME account. If it is False, run kubectl describe clusterissuer letsencrypt-staging; an invalidEmail error means exactly that.
Step 3: Choose HTTP-01 or DNS-01 (and configure DNS-01)
HTTP-01 is simplest: Let's Encrypt fetches http://<domain>/.well-known/acme-challenge/<token> from the internet, and cert-manager answers via a temporary solver pod and route. It cannot issue wildcards and needs port 80 reachable from the public internet. DNS-01 proves control by writing a _acme-challenge TXT record; it works for wildcards and private clusters, at the cost of giving cert-manager DNS API credentials.
You can mix both in one issuer with a selector. Here is a DNS-01 production issuer for Cloudflare, one of the in-tree providers listed in the DNS-01 docs. For a ClusterIssuer, the secret must live in the cert-manager namespace.
# Cloudflare API token scoped to Zone:DNS:Edit on a single zone only
kubectl create secret generic cloudflare-api-token \
--namespace cert-manager \
--from-literal=api-token='<paste-token-here>'
# clusterissuer-dns01.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod-dns
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ops@example.com
privateKeySecretRef:
name: letsencrypt-prod-dns-account
solvers:
- selector:
dnsZones:
- example.com # matches example.com and *.example.com
dns01:
cnameStrategy: None # set Follow if _acme-challenge is a CNAME to another zone
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
On AWS, the route53 solver with region and hostedZoneID plus IRSA or EKS Pod Identity avoids static keys entirely. If you have External Secrets Operator in place, sync the token from your vault rather than creating it by hand.
Step 4: Request a certificate
You can annotate a Gateway or Ingress with cert-manager.io/cluster-issuer and let cert-manager create the Certificate (see the Ingress usage docs), but an explicit Certificate is easier to debug. Put it in the Gateway's namespace so the listener can read the secret without a ReferenceGrant.
# certificate.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: app-example-com
namespace: gateway
spec:
secretName: app-example-com-tls # kubernetes.io/tls Secret cert-manager will write
dnsNames:
- app.example.com
issuerRef:
kind: ClusterIssuer
name: letsencrypt-staging # switch to letsencrypt-prod once this works
privateKey:
algorithm: ECDSA
size: 256
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: public
namespace: gateway
spec:
gatewayClassName: <your-gateway-class> # e.g. from Envoy Gateway, Cilium, Istio
listeners:
- name: http # port 80 listener used by HTTP-01 solver routes
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
- name: https
protocol: HTTPS
port: 443
hostname: app.example.com
tls:
mode: Terminate
certificateRefs:
- name: app-example-com-tls
allowedRoutes:
namespaces:
from: All
kubectl apply -f certificate.yaml
kubectl get certificate -n gateway -w
# NAME READY SECRET AGE
# app-example-com False app-example-com-tls 5s
# app-example-com True app-example-com-tls 48s
Once staging reaches True, change issuerRef.name to letsencrypt-prod and apply again. cert-manager reissues automatically.
Step 5: Follow the chain: Certificate, CertificateRequest, Order, Challenge
When a certificate stays False, do not guess. cert-manager's ACME troubleshooting page describes a fixed chain, and each object's events tell you which link broke.
NS=gateway
# The one-shot view: Certificate, CertificateRequest, Order and Challenges in one output
cmctl status certificate app-example-com -n "$NS"
# Or walk it by hand, top to bottom
kubectl describe certificate app-example-com -n "$NS"
kubectl get certificaterequest -n "$NS"
kubectl describe certificaterequest <name> -n "$NS" # look for the OrderCreated event
kubectl get order -n "$NS"
kubectl describe order <name> -n "$NS" # state pending -> valid; OrderValid event
kubectl get challenges -n "$NS" # STATE, DOMAIN, REASON
kubectl describe challenge <name> -n "$NS"
# The authorization URL, if you want Let's Encrypt's own view of the failure
kubectl get challenge <name> -n "$NS" -o jsonpath='{.spec.authorizationURL}'
# When it finally works, check what landed in the Secret
cmctl inspect secret app-example-com-tls --namespace "$NS"
A missing Challenge with an Order in errored usually points at the issuer or rate limits. A Challenge stuck in pending means validation has not passed yet; its REASON tells you whether cert-manager's own self-check is the blocker.
Step 6: Fix HTTP-01 challenge failures
cert-manager runs a self-check before it asks Let's Encrypt to validate. Waiting for http-01 challenge propagation means that self-check is failing. Reproduce it from outside the cluster:
# Get the token from the challenge, then fetch it the way Let's Encrypt will
TOKEN=$(kubectl get challenge <name> -n gateway -o jsonpath='{.spec.token}')
curl -sv "http://app.example.com/.well-known/acme-challenge/${TOKEN}"
# Expect HTTP 200 and a body of "<token>.<account-thumbprint>"
# Is the solver running, and did it get a route?
kubectl get pods,svc -n gateway -l acme.cert-manager.io/http01-solver=true
kubectl get httproute -n gateway
kubectl describe httproute -n gateway <cm-acme-http-solver-xxxxx> # check Accepted / ResolvedRefs
The common causes, from the troubleshooting docs:
- 404: the solver route is not attached. For Gateway API, check the port 80 listener exists and its
allowedRoutespermits the namespace; for Ingress, checkingressClassNamematches a running controller, or that two Ingresses are not fighting over the same host (acme.cert-manager.io/http01-edit-in-place: "true"helps there). - Timeouts or connection refused: port 80 is blocked at a firewall, security group or load balancer, or DNS points somewhere else.
- Public curl works, self-check fails: split-horizon DNS or NAT hairpinning means the cluster cannot reach its own public address. v1.21 added
waitInsteadOfSelfCheckon the solver as an escape hatch, which skips the self-check and waits a fixed duration instead:
solvers:
- waitInsteadOfSelfCheck: 30s # cert-manager >= v1.21; use only when self-check cannot work
http01:
gatewayHTTPRoute:
parentRefs:
- name: public
namespace: gateway
kind: Gateway
tls: handshake failureon redirect: if HTTP redirects to HTTPS before a certificate exists, setcert-manager.io/issue-temporary-certificate: "true"on the Certificate so there is something to serve.
Step 7: Fix DNS-01 challenge failures
Waiting for dns-01 challenge propagation means cert-manager cannot yet see the TXT record. First check whether the record exists at all:
# What the authoritative servers say
dig +short NS example.com
dig +short TXT _acme-challenge.app.example.com @<one-of-the-NS-servers>
# What public resolvers say
dig +short TXT _acme-challenge.app.example.com @1.1.1.1
If the record is missing everywhere, the provider call failed: kubectl describe challenge shows the API error (bad token, wrong zone, insufficient permissions). If it is visible publicly but cert-manager keeps waiting, the cluster's resolver is the problem (a split-horizon internal zone is the classic case, and CoreDNS troubleshooting covers the in-cluster side). Point the self-check at public resolvers, using the flags from the DNS-01 docs:
helm upgrade cert-manager oci://quay.io/jetstack/charts/cert-manager \
--version v1.21.2 --namespace cert-manager --reuse-values \
--set 'extraArgs={--dns01-recursive-nameservers-only,--dns01-recursive-nameservers=1.1.1.1:53\,8.8.8.8:53}'
cert-manager finds the zone by looking up SOA records. Resolvers that filter SOA (the docs call out dnsmasq's --filterwin2k) make it pick the wrong zone. And if _acme-challenge is a CNAME into a delegated validation zone, set cnameStrategy: Follow.
Step 8: Respect rate limits and turn on ARI
Testing against production is how people lock themselves out. Let's Encrypt's rate limits include 5 certificates per exact set of names per 7 days, 50 per registered domain per 7 days, 300 new orders per account per 3 hours, and 5 authorization failures per name per account per hour. Repeated failures can pause issuance for a name entirely: the docs give an example of 120 failures a day leading to a 10-day pause. Use staging until the chain is green.
Renewals that follow ACME Renewal Information (RFC 9773) are exempt from all those limits. cert-manager v1.21.0 added experimental ARI support behind the ACMEUseARI feature gate, which Step 1 enabled. Check that it is working:
kubectl get certificate app-example-com -n gateway -o jsonpath='{.status.acme.ari}'
# Expect suggestedWindow, lastChecked and nextCheck fields once the controller has polled
Step 9: Alert on expiry and failed renewals
Renewal failures are silent unless you look. cert-manager exports certmanager_certificate_ready_status, certmanager_certificate_expiration_timestamp_seconds and certmanager_certificate_renewal_timestamp_seconds; enable scraping with the chart's prometheus.servicemonitor.enabled=true if you run the Prometheus Operator.
# cert-manager-alerts.yaml (PrometheusRule for the Prometheus Operator)
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: cert-manager-alerts
namespace: cert-manager
spec:
groups:
- name: cert-manager
rules:
- alert: CertManagerCertNotReady
expr: certmanager_certificate_ready_status{condition="False"} == 1
for: 30m
labels: { severity: warning }
annotations:
summary: "Certificate {{ $labels.namespace }}/{{ $labels.name }} not ready for 30m"
- alert: CertManagerRenewalOverdue
# renewal time passed more than 6h ago and the cert was not replaced
expr: time() - certmanager_certificate_renewal_timestamp_seconds > 6 * 3600
labels: { severity: critical }
annotations:
summary: "Renewal overdue for {{ $labels.namespace }}/{{ $labels.name }}"
- alert: CertManagerCertExpiringSoon
expr: certmanager_certificate_expiration_timestamp_seconds - time() < 7 * 86400
labels: { severity: critical }
annotations:
summary: "{{ $labels.namespace }}/{{ $labels.name }} expires in under 7 days"
Route the critical ones to a pager. Two 2026 postmortems (ImprovMX, Lucidworks) had renewal or warning signals that existed but never reached a human.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
ClusterIssuer READY False, invalidEmail |
Bad or placeholder contact address | Use a real address in spec.acme.email |
unknown field spec.acme.profile |
cert-manager older than v1.18 | Upgrade, or remove profile |
does not advertise a profile with name |
Typo or profile not offered by that CA | Check the CA's directory meta.profiles |
Challenge pending, Waiting for http-01 challenge propagation |
Solver route not reachable from cert-manager | curl the token URL; check listener on port 80, allowedRoutes, firewall |
| HTTP-01 returns 404 | Route not attached, wrong ingress class, or duplicate host rules | kubectl describe httproute or Ingress; fix class; try http01-edit-in-place |
| Public curl works, self-check never passes | Split-horizon DNS or NAT hairpin | waitInsteadOfSelfCheck (v1.21+) |
Challenge pending, Waiting for dns-01 challenge propagation |
Cluster resolver cannot see public TXT | --dns01-recursive-nameservers-only with public resolvers |
| DNS-01 error about zone or permissions | Token scope wrong or SOA filtered | Scope token to the zone; fix resolver; set cnameStrategy if delegated |
Order errored, too many certificates or failed authorizations |
Let's Encrypt rate limit | Wait for refill; test on staging; enable ARI for renewals |
secrets "acme-key" already exists |
Leftover account secret from an old issuer | Delete the secret so cert-manager recreates it |
FAQ
Why is my cert-manager certificate stuck at READY False?
Walk the chain: cmctl status certificate <name> -n <ns> or kubectl describe on the Certificate, CertificateRequest, Order and Challenge in that order. In most cases the Challenge's REASON shows a self-check waiting on HTTP-01 reachability or DNS-01 propagation. Fix that and the rest completes on its own.
Should I use HTTP-01 or DNS-01 with cert-manager?
Use HTTP-01 when the hostname is publicly reachable on port 80 and you do not need wildcards. Use DNS-01 for wildcards, private clusters, or when port 80 is not open, and give cert-manager a DNS token scoped to a single zone.
How do I force cert-manager to renew a certificate?
Run cmctl renew <name> -n <namespace>, or cmctl renew --all -n <namespace> for every certificate in a namespace. Each forced renewal counts against Let's Encrypt rate limits, so do not loop it.
How often does cert-manager renew Let's Encrypt certificates?
By default at two thirds of the certificate's lifetime, unless you set renewBefore or renewBeforePercentage. With the tlsserver profile's 45-day certificates that is about day 30; with ARI enabled, the CA's suggested window can move it.
When a certificate goes red in production, the first thing I look at is kubectl get challenges -A, because the REASON column usually answers the question without opening anything else. The second is whether the alert reached a person, because the certificate that expires is rarely the one with a hard bug; it is the one whose renewal has been quietly failing for weeks. I keep staging issuers permanently installed next to production ones for exactly that reason. Official references: cert-manager ACME troubleshooting, ACME issuer configuration, cmctl reference and Let's Encrypt rate limits.
Related: Who Is This Agent? Non-Human Identity in 2026, From the RFCs to the Breaches, Kubernetes as the Agent Control Plane in 2026: Agent Sandbox Hit v1.0, 300 Claims a Second, and DRA in Every Supported Release and TLS Certificate Lifetimes in 2026: 200 Days Now, 47 Days by 2029.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Use External Secrets Operator with Vault on Kubernetes (2026)
Step-by-step External Secrets Operator v2.13 setup with HashiCorp Vault, OpenBao or AWS Secrets Manager: Helm install, Kubernetes auth, ClusterSecretStore, ExternalSecret, rotation and reloads, ESO's 2025 release pause, alternatives, and fixes for SecretSyncedError.
14 minOct 20, 2026How to Detect Runtime Threats with Falco on Kubernetes (2026)
Step-by-step Falco 0.45 setup on Kubernetes: Helm chart 9.2.0 with the modern eBPF driver, default and custom rules, Falcosidekick alerts to Slack, tuning noise, Falco Talon response actions, drop metrics, and a troubleshooting table for driver errors.
15 minOct 20, 2026How to Write Kubernetes NetworkPolicies: Default Deny to Egress (2026)
Step-by-step Kubernetes NetworkPolicy guide for 2026: default deny, allowing DNS, namespace and pod selectors, egress control, Cilium and Calico support, ClusterNetworkPolicy v1alpha2, testing with netshoot, and a troubleshooting table for blocked traffic.
14 min