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 caddy binary 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 popular lucaslorentz/caddy-docker-proxy and 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-md5 below 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.