Reverse Proxy + TLS
Caddy + DynIP
Caddy already does automatic Let's Encrypt out of the box, but its default HTTP-01 challenge needs port 80 reachable from the public internet. That's a non-starter behind CGNAT, behind a corporate firewall, or when port 80 is already taken. Caddy's RFC 2136 plugin and DynIP's authoritative DNS solve this: Caddy asks DynIP to inject the validation record over TSIG-authenticated DNS UPDATE, Let's Encrypt validates against DynIP's public nameservers, and you get a trusted cert without opening any inbound ports.
What you'll need
- Caddy v2.6 or newer. The default
caddybinary doesn't include the RFC 2136 plugin — you'll either build a custom binary with xcaddy or use a Docker image that bakes it in (the official image doesn't; the popularlucaslorentz/caddy-docker-proxyand several community builds do). - A DynIP zone. Either a standard zone under one of the community base zones or a BYOD namespace under your own domain.
- The zone's TSIG key from the dashboard. This guide uses HMAC-SHA256 (the DynIP default for new zones); the Caddy RFC 2136 plugin supports it. Your zone's current algorithm is shown in the dashboard's Options modal — if it's set to HMAC-MD5, either switch it to SHA-256 or set
DYNIP_TSIG_ALG=hmac-md5below to match. - The DynIP DNS update server hostname — shown in the dashboard's snippets-generator, typically
update.dynip.dev.
Setup
1. Build Caddy with the RFC 2136 plugin
Using xcaddy on the host that'll run Caddy:
xcaddy build --with github.com/caddy-dns/rfc2136
Or, if you're running Docker, pull a community image that includes the plugin and skip this step.
2. Export your TSIG credentials
Caddy reads RFC 2136 credentials from environment variables, which keeps them out of the Caddyfile. Put them in /etc/caddy/caddy.env (or your systemd unit's EnvironmentFile=):
DYNIP_DNS_SERVER=update.dynip.dev:53 DYNIP_TSIG_KEY_NAME=key-home.ddns.dynip.dev DYNIP_TSIG_KEY=YOUR_44_CHAR_BASE64_TSIG_SECRET DYNIP_TSIG_ALG=hmac-sha256
3. Write the Caddyfile
A minimal setup that issues a named cert for one hostname and reverse-proxies to a local service:
{
# Global TLS block — all sites in this Caddyfile use DNS-01 by default.
acme_dns rfc2136 {
key_name {env.DYNIP_TSIG_KEY_NAME}
key {env.DYNIP_TSIG_KEY}
key_alg {env.DYNIP_TSIG_ALG}
server {env.DYNIP_DNS_SERVER}
}
}
home.ddns.dynip.dev {
reverse_proxy localhost:3000
}
4. Start Caddy
First-run cert issuance takes 30–90 seconds while DynIP propagates the _acme-challenge TXT record to all secondaries and Let's Encrypt polls for it. Watch the logs:
$ caddy run --config /etc/caddy/Caddyfile --envfile /etc/caddy/caddy.env
# ...
INFO tls.obtain acquiring lock {"identifier": "home.ddns.dynip.dev"}
INFO tls.issuance.acme waiting on internal rate limiter
INFO tls.issuance.acme done waiting on internal rate limiter
INFO tls.issuance.acme.acme_client trying to solve challenge {"identifier": "home.ddns.dynip.dev", "challenge_type": "dns-01"}
INFO tls.issuance.acme.acme_client authorization finalized
INFO tls.obtain certificate obtained successfully
Verification
$ curl -I https://home.ddns.dynip.dev HTTP/2 200 server: Caddy $ openssl s_client -connect home.ddns.dynip.dev:443 -servername home.ddns.dynip.dev </dev/null 2>/dev/null \ | openssl x509 -noout -issuer -subject -dates issuer=C = US, O = Let's Encrypt, CN = R11 subject=CN = home.ddns.dynip.dev notBefore=... notAfter=...
Renewals happen automatically — Caddy attempts renewal 30 days before expiry using the same DNS-01 flow. You'll see another tls.obtain entry in the logs each time.
Common issues
"unknown module: dns.providers.rfc2136"
The running Caddy binary doesn't have the plugin compiled in. Either rebuild with xcaddy build --with github.com/caddy-dns/rfc2136 or switch to a Docker image that includes it. The default caddy binary from package managers is plugin-less.
"REFUSED" or "NOTAUTH" from the DNS UPDATE
The TSIG key name or secret is wrong, or DYNIP_TSIG_ALG doesn't match the zone's algorithm. Re-copy the key from the dashboard, and confirm DYNIP_TSIG_ALG matches the algorithm shown in the zone's Options modal (hmac-sha256 by default, or hmac-md5 if you switched it). The key name includes the key- prefix and the full zone name; missing either causes a NOTAUTH.
Validation times out on a freshly created zone
Brand-new zones need a few seconds for the secondaries to AXFR the TSIG key from the primary. The dashboard surfaces this as is_tsig_ready on the zone — wait until that flips before triggering Caddy. After the first issuance, this is no longer a factor.
Caddy keeps reissuing certs on every restart
Make sure Caddy's data directory (default /var/lib/caddy or $XDG_DATA_HOME/caddy) is persisted across restarts. Container deployments often lose this if the volume isn't mounted, which forces a fresh issuance each boot and burns through Let's Encrypt's rate limit.
Caddy in docker-compose
If you're running Caddy as a container rather than a host daemon, drop the dynip-updater container into the same docker-compose file. The two coexist cleanly — the updater keeps your A and AAAA records pointing at your current public IP; Caddy handles TLS termination and reverse proxy. The updater is independent of Caddy's startup order and shares no volumes with it. See the Docker container section of the docs for the snippet and full env reference.
Related guides
- Home Assistant remote access — uses this Caddy setup as the public TLS terminator in front of HA.
- Traefik + DynIP — if your stack is Docker-native, Traefik's labels are usually a better fit than a Caddyfile.
- Tailscale + DynIP — share the certificate Caddy obtains here with the tailnet side via split DNS.
- Netbird + DynIP — self-hosted mesh VPN running in docker-compose, the same pattern as the section above.