ACME DNS-01 Challenge
ACME DNS-01 Challenge
When ACME_DNS_ENABLED=true, DNS Resolver forwards all auth.DOMAIN queries to an acme-dns sidecar container, enabling the DNS-01 ACME challenge flow. This allows Traefik (or any ACME client using lego) to automatically issue certificates from an internal CA without exposing port 80.
How it works
sequenceDiagram
participant T as Traefik (lego)
participant CA as Internal CA
participant AD as acme-dns (sidecar)
participant CD as CoreDNS (dns-resolver)
T->>CA: POST /acme/order (request cert)
CA-->>T: DNS-01 challenge token
T->>AD: POST /update (write TXT record)
AD-->>T: 200 OK
CA->>CD: TXT _acme-challenge.svc.auth.DOMAIN?
CD->>AD: forward auth.DOMAIN → acme-dns:53
AD-->>CA: TXT <token>
CA-->>T: challenge passed → certificate
T->>AD: POST /update (clear TXT record)
- Traefik requests a certificate from the internal CA via ACME.
- The CA issues a DNS-01 challenge: create a TXT record at
_acme-challenge.<service>.auth.DOMAIN. - Traefik calls the acme-dns REST API (
POST /update) to write the TXT record. - The CA queries CoreDNS. Because
ACME_DNS_ENABLED=true, CoreDNS forwards allauth.DOMAINqueries to theacme-dnssidecar, which responds with the TXT record. - The challenge passes and the CA issues the certificate. Traefik cleans up the TXT record.
CoreDNS uses Docker's internal DNS to resolve the
acme-dnshostname — no fixed container IP is required.
Setup
1 — Enable in your .env
ACME_DNS_ENABLED=true
2 — Add the acme-dns sidecar to your compose
The acme-dns sidecar must be on the same Docker network (dns-net) as DNS Resolver.
The configs.content block below requires Docker Compose CLI v2.24+.
volumes:
acme_dns_data:
configs:
acme_dns_config:
content: |
[general]
listen = "0.0.0.0:53"
protocol = "both"
domain = "auth.${DOMAIN}"
nsname = "acme-dns.${DOMAIN}"
nsadmin = "hostmaster.${DOMAIN}"
debug = false
[database]
engine = "sqlite3"
connection = "/var/lib/acme-dns/acme-dns.db"
[api]
ip = "0.0.0.0"
port = "80"
tls = "none"
disable_registration = false
autoregister = false
use_header = false
[logconfig]
loglevel = "info"
logtype = "stdout"
logformat = "text"
networks:
dns-net:
name: dns-net
services:
dns:
image: ghcr.io/circle-rd/dns-resolver:latest
environment:
- DOMAIN=${DOMAIN}
- HOST_IP=${HOST_IP}
- ACME_DNS_ENABLED=true
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- "${HOST_IP}:53:53/udp"
- "${HOST_IP}:53:53/tcp"
networks:
- dns-net
acme-dns:
image: joohoi/acme-dns:latest
container_name: acme-dns
restart: unless-stopped
configs:
- source: acme_dns_config
target: /etc/acme-dns/config.cfg
volumes:
- acme_dns_data:/var/lib/acme-dns
networks:
- dns-net
3 — Configure Traefik
Add a dnsChallenge certificate resolver in Traefik's static configuration:
# traefik.yml — static configuration
certificatesResolvers:
internal:
acme:
caServer: "https://ca.home.example.com/acme/acme/directory"
storage: "/data/acme.json"
dnsChallenge:
provider: acmedns
resolvers:
- "${HOST_IP}:53"
Add the acmedns provider environment to the Traefik service:
# Traefik service in compose
environment:
- ACMEDNS_STORAGE_PATH=/data/acmedns.json # per-domain credentials (auto-created on first run)
- ACMEDNS_API_BASE=http://acme-dns:80 # acme-dns REST API (internal Docker network)
Traefik must share the
dns-netnetwork withacme-dnsto reach its REST API athttp://acme-dns:80.
4 — First-time registration
On the first certificate request, the acmedns lego provider automatically calls POST /register on acme-dns and saves the per-domain credentials to ACMEDNS_STORAGE_PATH. Subsequent renewals are fully automatic.
You can trigger registration manually to verify the setup:
curl -s -X POST http://<HOST_IP>:80/register | jq .
# → { "username": "...", "password": "...", "fulldomain": "<uuid>.auth.home.example.com", ... }
Testing
Verify that TXT queries reach acme-dns via CoreDNS:
# Should return an empty or non-existent TXT record (no challenge active)
dig @${HOST_IP} _acme-challenge.test.auth.home.example.com TXT +short
# Write a test TXT record manually
curl -s -X POST http://<HOST_IP>:80/update \
-H "Content-Type: application/json" \
-d '{"username":"<UUID>","password":"<PASSWORD>","subdomain":"<UUID>","txt":"test-value"}'
# Verify CoreDNS forwards the query to acme-dns and returns the TXT record
dig @${HOST_IP} _acme-challenge.<UUID>.auth.home.example.com TXT +short
# → "test-value"