Reverse Proxy + TLS
Traefik + DynIP
Traefik is a natural fit for Docker stacks: services declare their own routing and TLS via labels, and Traefik discovers them automatically. Pair it with DynIP and you get Let's Encrypt certificates through the DNS-01 challenge for every service in the stack — no per-service config, no exposed port 80, and no need to give every container its own DNS name. Traefik v3 ships with an RFC 2136 ACME resolver built in, so the only thing you need from DynIP is the TSIG key.
What you'll need
- Docker and Docker Compose v2 on the host.
- A DynIP zone (community or BYOD namespace) and its TSIG key from the dashboard.
- Port 443 forwarded from the router to the Docker host. Port 80 is optional — you only need it if you want HTTP-to-HTTPS redirects to work from the public side.
- A DDNS updater somewhere on the network so the zone tracks the host's public IP. The natural fit for a docker-compose host is the
dynip-updatercontainer, dropped into the same compose file — see the section below.
Setup
1. Stash the TSIG credentials
Create a .env file next to your docker-compose.yml:
RFC2136_NAMESERVER=update.dynip.dev:53 RFC2136_TSIG_KEY=key-stack.ddns.dynip.dev RFC2136_TSIG_ALGORITHM=hmac-sha256. RFC2136_TSIG_SECRET=YOUR_44_CHAR_BASE64_TSIG_SECRET [email protected]
This guide uses hmac-sha256 (the DynIP default for new zones). 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 use hmac-md5. here to match.
Note the trailing dot on hmac-sha256. — Traefik's underlying ACME library (lego) is strict about the canonical DNS algorithm form. Without it you'll get a TSIG error on the first issuance attempt.
2. Write the compose file
A minimal stack with Traefik and a sample whoami service, both behind the same DynIP zone:
services:
traefik:
image: traefik:v3.1
restart: unless-stopped
ports:
- "443:443"
env_file: .env
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.websecure.address=:443
- --certificatesresolvers.dynip.acme.email=${ACME_EMAIL}
- --certificatesresolvers.dynip.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.dynip.acme.dnschallenge=true
- --certificatesresolvers.dynip.acme.dnschallenge.provider=rfc2136
- --certificatesresolvers.dynip.acme.dnschallenge.resolvers=ns1.dynip.dev:53,ns2.dynip.dev:53
whoami:
image: traefik/whoami
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.whoami.rule=Host(`whoami.stack.ddns.dynip.dev`)
- traefik.http.routers.whoami.entrypoints=websecure
- traefik.http.routers.whoami.tls.certresolver=dynip
- traefik.http.services.whoami.loadbalancer.server.port=80
The dnschallenge.resolvers override tells lego to query DynIP's own nameservers when polling for the validation record, instead of public recursive resolvers. This eliminates the 30-second wait for caches to clear and is what makes DNS-01 issuance feel instant.
3. Make sure DNS for each hostname resolves
Every Host(`...`) rule needs the hostname to resolve to the Docker host's public IP. With a single DynIP zone you have two options: create one zone per hostname (works on the free tier up to the zone quota), or use one zone with a wildcard pattern via DynIP's BYOD namespace feature, where every subdomain under stack.ddns.dynip.dev resolves to the same IP automatically.
4. Start the stack
$ docker compose up -d $ docker compose logs -f traefik # Watch for: "Adding certificate for domains [whoami.stack.ddns.dynip.dev]" # Followed by: "The server validated our request"
Updating your A and AAAA records
DNS-01 validation only needs the TSIG key — not your public IP — so cert issuance works regardless of where the zone currently points. But for actual traffic to reach the stack, the zone has to resolve to the Docker host's current public IP. Since Traefik runs in docker-compose, the natural fit is the dynip-updater container in the same compose file: it detects the host's IPv4 and IPv6 from inside the container's network, reconciles against what's currently published in DNS, and pushes an update to /update whenever something diverges.
Add this service block alongside Traefik:
dynip-updater:
image: ghcr.io/33k-org/dynip-updater:latest
container_name: dynip-updater
restart: unless-stopped
environment:
- DYNIP_DOMAIN=stack.ddns.dynip.dev
- DYNIP_KEY=${RFC2136_TSIG_SECRET}
# Independent of Traefik — no depends_on, no shared volumes.
The TSIG secret is the same one Traefik already uses, so reusing RFC2136_TSIG_SECRET from .env avoids duplication. The container has no persistent state, runs as a non-root user, and exposes a /healthz endpoint on port 9090 inside the network. For the full env reference (interval, heartbeat, log level, v4/v6 toggles), see the Docker container section of the docs.
Verification
$ curl https://whoami.stack.ddns.dynip.dev Hostname: whoami-... IP: 172.18.0.3 # ... $ curl -vI https://whoami.stack.ddns.dynip.dev 2>&1 | grep -E 'issuer|subject' * Server certificate: * subject: CN=whoami.stack.ddns.dynip.dev * issuer: C=US; O=Let's Encrypt; CN=R11
Add more services by appending compose entries with their own labels — each one gets its own cert automatically. Traefik batches issuance so adding ten services at once doesn't fire ten parallel ACME flows.
Common issues
"unable to generate a certificate ... NOTAUTH"
The TSIG key name, secret, or algorithm doesn't match. Re-copy from the dashboard, and make sure RFC2136_TSIG_ALGORITHM matches the zone's algorithm with the trailing dot — hmac-sha256. by default, or hmac-md5. if you switched the zone in the Options modal. The key name always includes the key- prefix and the full zone name.
acme.json keeps getting permission warnings
Traefik refuses to write to acme.json if its permissions are too open. Set it to 0600 before the first start: touch ./letsencrypt/acme.json && chmod 600 ./letsencrypt/acme.json. The bind mount needs to exist as a file, not a directory, or Docker will create a directory instead.
Service routes work over HTTP but not HTTPS
If you only see HTTP traffic working, you probably haven't set entrypoints=websecure and tls.certresolver=dynip on the router labels. Traefik defaults to HTTP-only when those are missing. Confirm with docker compose exec traefik traefik version and check the dashboard at :8080 if you've enabled it.
Validation works once, then renewal fails months later
Usually a rotated TSIG key. If you regenerate a key in the DynIP dashboard, update .env and restart Traefik. The zone's tsig-status endpoint shows the active key fingerprint so you can confirm which one is currently authoritative.
Related guides
- Caddy + DynIP — for non-Docker hosts or when you want a single Caddyfile instead of compose labels.
- Home Assistant remote access — HA fits cleanly behind Traefik with one extra
Host(`ha.ddns.dynip.dev`)router. - Tailscale + DynIP — pair Traefik's public TLS with Tailscale's private mesh for trusted devices.
- Netbird + DynIP — the same Traefik+compose pattern used here, with Netbird's installer doing the initial scaffolding.