| name | certmanager-dns01-gke-private-cluster |
| description | Fix cert-manager DNS01 ACME challenges stuck in "pending" state with "DNS record not yet
propagated" inside GKE private clusters, even when TXT records exist in Cloudflare DNS.
Use when: (1) cert-manager challenges show "pending" for hours with propagation check failures,
(2) dig from outside cluster shows correct TXT records but cert-manager can't verify them,
(3) Using Cloudflare DNS01 solver in a GKE private cluster with Cloud NAT,
(4) Google Cloud intercepts 8.8.8.8 DNS queries returning NXDOMAIN for Cloudflare-managed records,
(5) Even --dns01-recursive-nameservers with 1.1.1.1 doesn't fix the propagation check despite
TXT records being verifiable from busybox pods in the same namespace. Covers the full debugging
flow and certbot manual workaround.
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-03-28T00:00:00.000Z" |
Cert-Manager DNS01 Challenge Failure in GKE Private Clusters
Problem
Cert-manager DNS01 ACME challenges get stuck in "pending" state indefinitely inside GKE private
clusters. The propagation check fails with "DNS record for X not yet propagated" even though
the TXT records are correctly created in Cloudflare and verifiable from everywhere — including
from inside the cluster using busybox pods.
Context / Trigger Conditions
- cert-manager with Cloudflare DNS01 solver
- GKE private cluster (private nodes, public endpoint) with Cloud NAT
- Challenges show
presented: true but state: pending for hours
- cert-manager logs show:
"propagation check failed" err="DNS record for \"example.com\" not yet propagated"
dig TXT _acme-challenge.example.com from outside returns correct value
- busybox
nslookup from inside cluster also returns correct value
- Certificate resource shows
Ready: False with reason: RequestChanged
Root Causes Discovered
1. Google Cloud intercepts DNS to 8.8.8.8
Inside GKE VPCs, DNS queries to 8.8.8.8 are intercepted by Google Cloud infrastructure.
For Cloudflare-managed domains, this can return NXDOMAIN even when the record exists.
This is because Google routes 8.8.8.8 through their internal DNS infrastructure which may
have different resolution behavior than the public Google DNS service.
Verification:
kubectl run dns-test --image=busybox:1.36 --rm -i --restart=Never -- \
nslookup -type=TXT _acme-challenge.example.com 8.8.8.8
kubectl run dns-test --image=busybox:1.36 --rm -i --restart=Never -- \
nslookup -type=TXT _acme-challenge.example.com 1.1.1.1
2. Cert-manager propagation check still fails with correct resolvers
Even after configuring --dns01-recursive-nameservers=1.1.1.1:53,1.0.0.1:53 and
--dns01-recursive-nameservers-only=true, cert-manager's propagation check may still fail.
The Go DNS library used by cert-manager (miekg/dns) behaves differently from busybox's
nslookup. The exact cause is unclear but may relate to:
- DNS response parsing differences between miekg/dns and system resolvers
- TCP vs UDP DNS query differences
- Internal cert-manager caching or timing issues
- Cloud NAT interaction with DNS traffic patterns
Solution
Attempt 1: Configure recursive DNS resolvers (may not be sufficient)
Add to cert-manager Helm values:
dns01RecursiveNameservers: "1.1.1.1:53,1.0.0.1:53"
dns01RecursiveNameserversOnly: true
IMPORTANT: Do NOT use 8.8.8.8 — Google Cloud intercepts this inside GKE VPCs.
For ArgoCD-managed cert-manager (Helm chart), add to the Application valuesObject:
valuesObject:
dns01RecursiveNameservers: "1.1.1.1:53,1.0.0.1:53"
dns01RecursiveNameserversOnly: true
Attempt 2: Manual cert generation with certbot (reliable workaround)
If the resolver fix doesn't work, generate the cert locally and inject it:
pipx install certbot
pipx inject certbot certbot-dns-cloudflare
CF_TOKEN=$(kubectl get secret cloudflare-api-token-secret -n cert-manager \
-o jsonpath='{.data.api-token}' | base64 -d)
mkdir -p /tmp/certbot-cf
echo "dns_cloudflare_api_token = $CF_TOKEN" > /tmp/certbot-cf/cloudflare.ini
chmod 600 /tmp/certbot-cf/cloudflare.ini
certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /tmp/certbot-cf/cloudflare.ini \
--dns-cloudflare-propagation-seconds 30 \
-d '*.example.com' -d 'example.com' \
--non-interactive --agree-tos --email admin@example.com \
--config-dir /tmp/certbot-cf/config \
--work-dir /tmp/certbot-cf/work \
--logs-dir /tmp/certbot-cf/logs \
--key-type ecdsa --elliptic-curve secp256r1
kubectl create secret tls wildcard-tls-secret \
--cert=/tmp/certbot-cf/config/live/example.com/fullchain.pem \
--key=/tmp/certbot-cf/config/live/example.com/privkey.pem \
-n nginx-gateway --dry-run=client -o yaml | kubectl apply -f -
rm -rf /tmp/certbot-cf
Verification
echo | openssl s_client -connect upload.example.com:443 \
-servername upload.example.com 2>/dev/null | \
openssl x509 -noout -subject -ext subjectAltName
curl -s https://upload.example.com/
Debugging Commands
kubectl get challenges -n nginx-gateway
kubectl get challenge <name> -n nginx-gateway \
-o jsonpath='domain: {.spec.dnsName}, key: {.spec.key}, presented: {.status.presented}'
kubectl get deployment cert-manager -n cert-manager \
-o jsonpath='{.spec.template.spec.containers[0].args}'
kubectl get certificate wildcard-tls -n nginx-gateway -o yaml
kubectl get secret wildcard-tls-secret -n nginx-gateway \
-o jsonpath='{.data.tls\.crt}' | base64 -d | \
openssl x509 -noout -subject -ext subjectAltName
kubectl run dns-test --image=busybox:1.36 --rm -i --restart=Never -- \
nslookup -type=TXT _acme-challenge.example.com 1.1.1.1
kubectl delete order <order-name> -n nginx-gateway
Notes
- The manually generated cert expires after 90 days and won't auto-renew
- cert-manager will eventually overwrite the manually injected secret when/if it
successfully issues its own cert — this is fine and desired
- When requesting both wildcard (
*.example.com) and base (example.com), ACME requires
separate authorizations that both use _acme-challenge.example.com TXT records with
different values
- ArgoCD root apps with
automated.enabled: false won't auto-sync — you need to trigger
manually via kubectl patch app root -n argocd --type merge -p '{"operation":{"sync":...}}'
- The cert-manager container is distroless (no shell) — you can't exec into it to debug DNS