En corto

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

cert-manager crea un Ingress temporal (cm-acme-http-solver-…) para responder al reto, pero sin clase tu ingress controller no lo adopta; la petición a /.well-known/acme-challenge/… la contesta el Ingress de la aplicación, que redirige a HTTPS con 308. Pon la clase en el 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; antes: class: nginx

Aplica y borra el reto atascado para que lo reintente:

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

Diagnóstico

1
2
3
4
kubectl get certificate,certificaterequest,order,challenge -A
kubectl describe challenge -n <ns> <nombre>            # Status → Reason es la frase que buscas
kubectl get ingress -n <ns>                            # el cm-acme-http-solver-xxxx tiene CLASS <none>?
curl -i http://app.example.com/.well-known/acme-challenge/prueba   # desde fuera y desde un pod

Antes de pedirle a Let’s Encrypt que valide, cert-manager hace un self-check: intenta él mismo la URL del reto. El Reason del Challenge es el resultado de ese self-check y casi siempre apunta a la causa.

ReasonCausaArreglo
wrong status code '308' (o 301/302)El Ingress del solver no tiene clase; responde el de la app con ssl-redirectingressClassName en el solver
wrong status code '404'El solver no está corriendo, o el DNS del host apunta a otra IPkubectl get pods -n <ns> | grep acme-solver; dig app.example.com
dial tcp … i/o timeout / connection refusedDesde dentro del clúster no se llega a la IP pública: NAT sin hairpin, DNS distinto dentro y fueraNAT reflection o split DNS en el firewall (pfSense)
tls: handshake failureEl Ingress de la app ya tiene TLS con un secreto vacíoAnotación cert-manager.io/issue-temporary-certificate: "true"
wrong status code '503'Varios Ingress con el mismo host y el controller no los combinaacme.cert-manager.io/http01-edit-in-place: "true" en el Ingress

Por qué ingressClassName

Desde Kubernetes 1.18 la clase de un Ingress va en spec.ingressClassName; la anotación kubernetes.io/ingress.class quedó obsoleta y los controllers modernos (ingress-nginx 1.x, Traefik) pueden ignorarla. En cert-manager, el campo class del solver sigue escribiendo la anotación; ingressClassName (desde la 1.12) escribe el campo. Usa uno de los dos, y en 2026 el que toca es ingressClassName.

Sin clase, el Ingress temporal existe pero nadie lo sirve. El controller enruta la petición por host, encuentra el Ingress de la app, y aplica sus reglas: ssl-redirect → 308. De ahí que el error parezca de la app y no de cert-manager.

Alternativa: DNS-01 con Cloudflare

Cuando el clúster no es alcanzable desde Internet por el puerto 80, o quieres un wildcard, el reto va por DNS. Token en Cloudflare con Zone → DNS → Edit y Zone → Zone → Read sobre la zona:

 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

Con DNS-01 no hay Ingress temporal, ni self-check por HTTP, ni hairpin; el self-check consulta el registro TXT, y si el DNS del clúster resuelve contra un servidor interno con la zona cacheada, el Reason será DNS record for … not yet propagated: paciencia o --dns01-recursive-nameservers=1.1.1.1:53 en cert-manager.

Trampas

  • Staging primero. https://acme-staging-v02.api.letsencrypt.org/directory no tiene límites de emisión; producción bloquea 5 emisiones por nombre y semana, y cada Challenge fallido que llega a Let’s Encrypt cuenta como intento.
  • Borrar el Challenge, no el Certificate. Borrar el Order o el Challenge fuerza un reintento con la configuración nueva; borrar el Certificate también borra el secreto y deja la app sin TLS mientras tanto.
  • pathType: Exact en el Ingress del solver (cert-manager reciente) choca con algunos controllers viejos; si ves 503 en OpenShift, es eso.
  • Un Issuer por namespace vs ClusterIssuer: el Ingress tiene que anotar el que exista (cert-manager.io/cluster-issuer o cert-manager.io/issuer); anotar el que no existe deja el Certificate en Issuing para siempre sin Challenge que describir.

Referencias: HTTP01 solver, Cloudflare DNS01 y Troubleshooting ACME en la documentación de cert-manager.