TL;DR
| |
cert-manager creates a temporary Ingress (cm-acme-http-solver-…) to answer the challenge, but without a class your ingress controller does not adopt it; the request to /.well-known/acme-challenge/… gets answered by the application’s Ingress, which redirects to HTTPS with 308. Put the class in the solver:
| |
Apply and delete the stuck challenge so it retries:
| |
Diagnosis
| |
Before asking Let’s Encrypt to validate, cert-manager runs a self-check: it tries the challenge URL itself. The Challenge’s Reason is the result of that self-check and almost always points at the cause.
Reason | Cause | Fix |
|---|---|---|
wrong status code '308' (or 301/302) | The solver Ingress has no class; the app’s one answers with ssl-redirect | ingressClassName in the solver |
wrong status code '404' | The solver is not running, or the host’s DNS points at another IP | kubectl get pods -n <ns> | grep acme-solver; dig app.example.com |
dial tcp … i/o timeout / connection refused | The public IP is unreachable from inside the cluster: NAT without hairpin, different DNS inside and outside | NAT reflection or split DNS on the firewall (pfSense) |
tls: handshake failure | The app’s Ingress already has TLS with an empty secret | Annotation cert-manager.io/issue-temporary-certificate: "true" |
wrong status code '503' | Several Ingresses with the same host and the controller does not merge them | acme.cert-manager.io/http01-edit-in-place: "true" on the Ingress |
Why ingressClassName
Since Kubernetes 1.18 an Ingress’s class goes in spec.ingressClassName; the kubernetes.io/ingress.class annotation is deprecated and modern controllers (ingress-nginx 1.x, Traefik) may ignore it. In cert-manager, the solver’s class field still writes the annotation; ingressClassName (since 1.12) writes the field. Use one of the two, and in 2026 the right one is ingressClassName.
Without a class, the temporary Ingress exists but nobody serves it. The controller routes the request by host, finds the app’s Ingress, and applies its rules: ssl-redirect → 308. That is why the error looks like the app’s and not cert-manager’s.
Alternative: DNS-01 with Cloudflare
When the cluster is not reachable from the Internet on port 80, or you want a wildcard, the challenge goes through DNS. Token in Cloudflare with Zone → DNS → Edit and Zone → Zone → Read on the zone:
| |
With DNS-01 there is no temporary Ingress, no HTTP self-check, no hairpin; the self-check queries the TXT record, and if the cluster’s DNS resolves against an internal server with the zone cached, the Reason will be DNS record for … not yet propagated: patience, or --dns01-recursive-nameservers=1.1.1.1:53 on cert-manager.
Traps
- Staging first.
https://acme-staging-v02.api.letsencrypt.org/directoryhas no issuance limits; production blocks 5 issuances per name per week, and every failed Challenge that reaches Let’s Encrypt counts as an attempt. - Delete the Challenge, not the Certificate. Deleting the
Orderor theChallengeforces a retry with the new configuration; deleting theCertificatealso deletes the secret and leaves the app without TLS meanwhile. pathType: Exacton the solver Ingress (recent cert-manager) clashes with some old controllers; if you see 503 on OpenShift, that is it.- Issuer per namespace vs ClusterIssuer: the Ingress must annotate the one that exists (
cert-manager.io/cluster-issuerorcert-manager.io/issuer); annotating the one that does not leaves the Certificate inIssuingforever with no Challenge to describe.
References: HTTP01 solver, Cloudflare DNS01, and Troubleshooting ACME in the cert-manager documentation.