HTTP-01 is the easy path to Let's Encrypt: expose port 80, answer the challenge, get a certificate. It stops being easy the moment you have a dozen self-hosted services, a reverse proxy behind CGNAT, or a wildcard domain you would rather not enumerate in public certificate transparency logs.
DNS-01 solves all three. You prove ownership by writing a TXT record, so no inbound port is needed and one certificate can cover *.lab.example.com and everything under it.
Prerequisites
- Traefik v3 with the Docker provider
- A DNS host with an API (Cloudflare, Route 53, Hetzner, deSEC …) and an API token scoped to zone-DNS-edit only
- Docker networks for the proxy and for your services
1. Lock down the token first
Whatever provider you use, the token can rewrite your DNS. Scope it to a single zone and to DNS record permissions. A leaked token on a full-account API key is a domain takeover, and DNS-01 means the token sits in a container environment variable.
Store it as a file rather than in the Compose file:
echo 'CF_DNS_API_TOKEN=<scoped-token>' > /srv/traefik/le.env
chmod 600 /srv/traefik/le.env
2. Traefik static configuration
# /srv/traefik/traefik.yml
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
certificatesResolvers:
le:
acme:
email: admin@example.com
storage: /data/acme.json
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
- "9.9.9.9:53"
delayBeforeCheck: 15
providers:
docker:
exposedByDefault: false
network: proxy
resolvers is not decoration. With DNS-01, Let's Encrypt asks the authoritative servers, but Traefik checks propagation using *your* resolver. If it inherits a slow or negative-caching resolver, validation fails intermittently with NXDOMAIN errors that make no sense.
3. One certificate for the wildcard
services:
traefik:
image: traefik:v3.1
restart: unless-stopped
env_file: /srv/traefik/le.env
ports: ["80:80", "443:443"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /srv/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- /srv/traefik/data:/data
networks: [proxy]
labels:
traefik.enable: "true"
# request the wildcard explicitly
traefik.http.routers.traefik.tls.domains[0].main: lab.example.com
traefik.http.routers.traefik.tls.domains[0].sans: "*.lab.example.com"
That label pair is what turns per-service HTTP-01 requests into a single DNS-01 wildcard. Without it Traefik issues one certificate per hostname and hits the 50-per-domain weekly rate limit during a migration.
4. Point services at it
whoami:
image: traefik/whoami:latest
networks: [proxy]
labels:
traefik.enable: "true"
traefik.http.routers.whoami.rule: Host(`whoami.lab.example.com`)
traefik.http.routers.whoami.tls.certResolver: le
traefik.http.services.whoami.loadbalancer.server.port: "80"
No ports published, no certificates to copy around, no nginx reload.
5. Verify before you trust it
# coupon: does the served cert actually cover the wildcard?
curl -svI https://whoami.lab.example.com 2>&1 | grep -E 'subject|issuer|SSL'
echo | openssl s_client -connect whoami.lab.example.com:443 \
-servername whoami.lab.example.com 2>/dev/null | openssl x509 -noout -text \
| grep -A1 'Subject Alternative Name'
# ACME state
docker compose exec traefik ls -l /data/acme.json
jq '.le.Certificates | length' /srv/traefik/data/acme.json
You want exactly one certificate whose SAN list contains DNS:*.lab.example.com, not five certificates each covering one host.
Pitfalls
acme.jsonpermissions. Traefik refuses to start if the file is not600. It contains private keys, so that is a feature.- Recreating the container during a challenge. The challenge state is in
acme.json; if you wipe/datamid-challenge you get a burst of failed authorisations and back off for an hour. - CAA records. If you publish
CAAfor the zone,letsencrypt.orgmust be allowed forissue*and*issuewild, otherwise the wildcard is refused silently in the Traefik logs. - Split-horizon DNS. Internal clients resolving the wildcard to a private address is fine; just make sure the TXT record is written to the public zone.
With the wildcard in place, adding a service is three labels and a docker compose up -d. That is the whole point.