How to Set Up cert-manager with Let's Encrypt and Fix Errors (2026)

Oct 20, 2026 · 14 min · Ajay Kumar

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.

Where a cert-manager ACME certificate gets stuck Certificate describe certificate READY, events CertificateRequest describe certificaterequest OrderCreated event Order describe order pending, then valid Challenge describe challenge most failures live here HTTP-01 solver pod + HTTPRoute or Ingress on port 80 self-check GET /.well-known/acme-challenge/ DNS-01 TXT at _acme-challenge.domain self-check via recursive DNS ACME server validates, Secret is written Self-check stuck = "Waiting for http-01/dns-01 challenge propagation" in kubectl get challenges REASON
The ACME resource chain as described in cert-manager's troubleshooting docs, with the command that inspects each object. Most real failures surface on the Challenge.
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 allowedRoutes permits the namespace; for Ingress, check ingressClassName matches 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 waitInsteadOfSelfCheck on 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 failure on redirect: if HTTP redirects to HTTPS before a certificate exists, set cert-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