TL;DR

1
Waiting for HTTP-01 challenge propagation: wrong status code '308', expected '200'

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
    - http01:
        ingress:
          ingressClassName: nginx        # cert-manager >= 1.12; before: class: nginx

Apply and delete the stuck challenge so it retries:

1
kubectl delete challenge -n <ns> --all

Diagnosis

1
2
3
4
kubectl get certificate,certificaterequest,order,challenge -A
kubectl describe challenge -n <ns> <name>              # Status → Reason is the line you want
kubectl get ingress -n <ns>                            # does cm-acme-http-solver-xxxx show CLASS <none>?
curl -i http://app.example.com/.well-known/acme-challenge/test   # from outside and from a pod

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.

ReasonCauseFix
wrong status code '308' (or 301/302)The solver Ingress has no class; the app’s one answers with ssl-redirectingressClassName in the solver
wrong status code '404'The solver is not running, or the host’s DNS points at another IPkubectl get pods -n <ns> | grep acme-solver; dig app.example.com
dial tcp … i/o timeout / connection refusedThe public IP is unreachable from inside the cluster: NAT without hairpin, different DNS inside and outsideNAT reflection or split DNS on the firewall (pfSense)
tls: handshake failureThe app’s Ingress already has TLS with an empty secretAnnotation cert-manager.io/issue-temporary-certificate: "true"
wrong status code '503'Several Ingresses with the same host and the controller does not merge themacme.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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
apiVersion: v1
kind: Secret
metadata:
  name: cloudflare-api-token
  namespace: cert-manager
type: Opaque
stringData:
  api-token: <token>
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-dns
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@example.com
    privateKeySecretRef:
      name: letsencrypt-dns-account-key
    solvers:
    - dns01:
        cloudflare:
          apiTokenSecretRef:
            name: cloudflare-api-token
            key: api-token

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/directory has 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 Order or the Challenge forces a retry with the new configuration; deleting the Certificate also deletes the secret and leaves the app without TLS meanwhile.
  • pathType: Exact on 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-issuer or cert-manager.io/issuer); annotating the one that does not leaves the Certificate in Issuing forever with no Challenge to describe.

References: HTTP01 solver, Cloudflare DNS01, and Troubleshooting ACME in the cert-manager documentation.