Kubernetes · TLS
Using cert-manager with DynIP
This guide walks you through issuing Let's Encrypt TLS certificates for a DynIP zone using cert-manager and the ACME DNS-01 challenge over RFC 2136 / TSIG. By the end you'll have automatically renewing certificates for a hostname you control — no port 80, no HTTP-01, no public ingress required.
What this is for
The ACME DNS-01 challenge proves you control a domain by writing a _acme-challenge TXT record. Because DynIP serves your zone and accepts TSIG-authenticated RFC 2136 UPDATEs, cert-manager can write that record itself, prove ownership, and get a certificate — all without exposing any HTTP endpoint to the internet.
cert-manager with DynIP gets you:
- Automatically issued and renewed Let's Encrypt certificates, stored as Kubernetes Secrets
- Wildcard-capable, port-80-free issuance via DNS-01 (works behind CGNAT, on private clusters, anywhere)
- TSIG-signed challenge records over RFC 2136 — the same protocol BIND
nsupdateuses - A single zone you fully control
If you've used cert-manager with Route53 or Cloudflare DNS-01, the workflow is identical — only the solver configuration changes.
Why use the generated snippet
cert-manager's RFC 2136 solver documentation covers the configuration in full, but it leaves you to assemble the manifest by hand: create a Secret from your base64 TSIG key, look up the right tsigAlgorithm string, get the key name and nameserver exactly right, and stitch it all into a ClusterIssuer.
DynIP's dashboard does that for you. Open Snippets on your zone, choose cert-manager (Kubernetes RFC 2136), and you get a complete, ready-to-apply manifest — the Secret, ClusterIssuer, and Certificate — pre-filled with your zone's actual TSIG key, the correct algorithm (HMACSHA256), the trailing-dot key name (key-<zone>.), and the DynIP update server (update.dynip.dev:53). The two values that can't be inferred — your Let's Encrypt contact email and the target namespace — are required inputs in the form, so what you copy is already filled in.
This guide explains what that manifest does and the two cluster-level things you still need to set up: the controller's recursive-nameserver flag and DNSSEC on the zone.
What you'll need
- A Kubernetes cluster you can
kubectl applyto (1.24+). Examples use k3d for a local cluster, but any cluster works. - cert-manager installed in the cluster. If you haven't yet, follow the official installation docs. This guide was validated against cert-manager v1.20.2.
- A DynIP account with a zone provisioned. We'll use
example.ddns.dynip.devthroughout; substitute your own zone. - The zone's TSIG algorithm set to HMAC-SHA256 (the DynIP default for new zones). The snippet hardcodes
HMACSHA256; if your zone is on HMAC-MD5, flip it in the dashboard's Options modal first — the secret stays the same. - DNSSEC enabled on the zone — see the next section for why this matters.
The DNSSEC precondition
Before cert-manager can validate a challenge, it does a self-check: it queries public recursive resolvers (by default 1.1.1.1 and 8.8.8.8) to confirm the _acme-challenge TXT record it just wrote is visible. Let's Encrypt's validators do the same thing from their side.
If your zone sits under a signed parent but is itself unsigned, the chain of trust is broken. Validating resolvers can't prove the absence-or-presence of records in an insecure child of a secure parent, so they return SERVFAIL (with EDE 12, "NSEC Missing") instead of an answer. cert-manager sees the lookup fail and never completes the challenge — the order hangs forever.
Fix: enable DNSSEC on the zone
In the dashboard, enable DNSSEC for the zone (the SSL / certificate flow prompts you to do this; it's the same gate DynIP's own certificate issuance uses). Once the child is signed and its DS record is published in the parent, the chain validates and resolvers answer normally. The cert-manager Snippets panel checks this for you and shows a warning until DNSSEC is on.
If your zone's parent is not signed (for example a delegated domain you brought yourself, where you control the parent's DNSSEC), there's no broken chain to worry about and this step doesn't apply.
Step 1: Configure the cert-manager controller
By default, cert-manager's DNS-01 self-check uses the cluster's own DNS for the propagation lookup. On many clusters — k3d's defaults, clusters with restricted egress, anything where in-cluster DNS can't cleanly resolve external authoritative records — that lookup returns SERVFAIL on a signed zone, and issuance stalls.
Point the self-check at public recursive resolvers instead by adding two controller args:
--dns01-recursive-nameservers-only --dns01-recursive-nameservers=1.1.1.1:53,8.8.8.8:53
If you installed cert-manager with Helm, set them like this:
helm upgrade cert-manager jetstack/cert-manager \
--namespace cert-manager --reuse-values \
--set 'extraArgs={--dns01-recursive-nameservers-only,--dns01-recursive-nameservers=1.1.1.1:53\,8.8.8.8:53}'
The backslash before the comma in 1.1.1.1:53\,8.8.8.8:53 escapes it so Helm doesn't split the value into two list items. After the upgrade, the controller pod restarts with the new flags. This is a one-time, cluster-wide setting — it applies to every issuer, not just DynIP.
Step 2: Generate the manifest
In the dashboard, click Snippets on your zone, choose cert-manager (Kubernetes RFC 2136), and fill in the two required fields:
- Contact Email — becomes the Let's Encrypt account contact. Renewal and expiry notifications go here.
- Kubernetes Namespace — the namespace where the issued certificate Secret will be created. Defaults to
default; set it to wherever your app runs (app,ingress, etc.).
Copy the generated YAML to a file — cert-manager.yaml for this guide. It contains three resources:
- A
Secretin thecert-managernamespace holding your TSIG key - A
ClusterIssuerwired to Let's Encrypt with the RFC 2136 solver pointing atupdate.dynip.dev:53 - A
Certificatein your chosen namespace requesting a cert for the zone
The generated solver block looks like this (values filled in for your zone):
solvers:
- dns01:
rfc2136:
nameserver: update.dynip.dev:53
tsigKeyName: key-example.ddns.dynip.dev.
tsigAlgorithm: HMACSHA256
tsigSecretSecretRef:
name: dynip-tsig-key-example-ddns-dynip-dev
key: tsig-secret-key
tsigKeyName — key-example.ddns.dynip.dev. matches the fully-qualified name DynIP stores. The Snippets generator gets this right; if you hand-edit the manifest, keep the dot.
Step 3: Apply and watch
kubectl apply -f cert-manager.yaml
You should see:
secret/dynip-tsig-key-example-ddns-dynip-dev created clusterissuer.cert-manager.io/dynip-letsencrypt-example-ddns-dynip-dev created certificate.cert-manager.io/example-ddns-dynip-dev-tls created
Watch the challenge progress — this is the single most useful command:
kubectl get challenges -A -w
It starts in pending while cert-manager writes the TXT record and waits for it to propagate, then flips to valid:
NAMESPACE NAME STATE DOMAIN AGE default example-ddns-dynip-dev-tls-1-...-... valid example.ddns.dynip.dev 2m
In testing against a real zone, issuance completed in about two minutes — roughly the time it takes the challenge TXT to become visible to the recursive resolvers cert-manager polls.
Reading the logs
If you tail the controller (kubectl logs -f -n cert-manager deploy/cert-manager), the issuance is noisier than you'd expect. Most of the scary-looking lines are normal. Here's what actually matters.
The challenge is presented
cert-manager writes the _acme-challenge TXT record over RFC 2136:
dns.go:91] "presenting DNS01 challenge for domain" dnsName="example.ddns.dynip.dev" type="DNS-01"
"propagation check failed" — this is normal
You'll see this repeated, at error level, for a minute or two. It is not a failure — it's cert-manager polling until the record it just wrote becomes visible to the recursive resolvers:
sync.go:187] "propagation check failed" err="DNS record for \"example.ddns.dynip.dev\" not yet propagated"
As long as it eventually stops and the challenge goes valid, ignore these. If it repeats for more than a few minutes, jump to Troubleshooting — that usually means a DNSSEC or recursive-nameserver problem.
Benign startup churn
These lines appear while the issuer registers its ACME account and resources settle. They're transient and self-correcting — no action needed:
controller.go:155] "re-queuing item due to error processing" err="ACME client for issuer not initialised/available" setup.go:276] "ACME server URL host and ACME private key registration host differ. Re-checking ACME account registration" controller.go:150] "re-queuing item due to optimistic locking on resource" ...the object has been modified...
The "re-checking account registration" line is followed shortly by "verified existing registration with ACME server" — that's the issuer becoming ready. The optimistic-locking re-queues are just two controllers updating the same object; Kubernetes retries automatically.
Success
The line you're waiting for:
acme.go:237] "certificate issued" resource_kind="CertificateRequest" ... conditions.go:201] "Found status change for Certificate condition" condition="Ready" oldStatus="False" status="True"
The Certificate's Ready condition flips False → True. Your cert is in the Secret.
Verify the certificate
kubectl get certificate -n default example-ddns-dynip-dev-tls
NAME READY SECRET AGE example-ddns-dynip-dev-tls True example-ddns-dynip-dev-tls-secret 3m
READY True means the Secret now holds a valid Let's Encrypt certificate and private key, ready to mount into an Ingress or your app. cert-manager renews it automatically before expiry — nothing else to do.
To inspect the issued cert directly:
kubectl get secret example-ddns-dynip-dev-tls-secret -n default \
-o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject -dates
Troubleshooting
"propagation check failed" never stops
The challenge TXT isn't becoming visible to the resolvers cert-manager queries. Two usual causes: (1) the controller isn't using public recursive nameservers — confirm Step 1's flags are actually set (kubectl get deploy -n cert-manager cert-manager -o yaml | grep dns01-recursive); (2) the zone is unsigned under a signed parent, so resolvers SERVFAIL — enable DNSSEC (see the DNSSEC precondition).
Resolvers return SERVFAIL for the zone
Verify it directly: dig +dnssec @1.1.1.1 _acme-challenge.example.ddns.dynip.dev TXT. A SERVFAIL with an "NSEC Missing" extended error is the unsigned-child symptom. Enabling DNSSEC on the zone fixes it.
TSIG BADKEY / signature failures on UPDATE
The algorithm in the manifest must match the zone. The snippet uses HMACSHA256; if your zone is on HMAC-MD5, switch it to HMAC-SHA256 in the dashboard Options modal (the secret is unchanged) and re-generate the snippet. Also confirm tsigKeyName kept its trailing dot.
ClusterIssuer never becomes Ready
Check kubectl describe clusterissuer dynip-letsencrypt-example-ddns-dynip-dev. Transient ACME client for issuer not initialised/available messages during the first few seconds are normal; a persistent error usually points at the ACME account email or network egress to Let's Encrypt.
Secret not found in my app's namespace
The Certificate creates its Secret in its own namespace — whatever you entered in the Namespace field when generating the snippet. If your app runs elsewhere, regenerate with the right namespace, or reference the cert from the namespace it lives in.
What's next
- cert-manager RFC 2136 solver reference — the upstream configuration the generated snippet implements for you
- external-dns with DynIP — publish DNS records from Service/Ingress annotations over the same RFC 2136 / TSIG path
- DynIP API & RFC 2136 reference — TSIG keys, algorithms, and the update protocols
- Let's Encrypt documentation — rate limits, staging environment, and ACME details