TL;DR
Delegate a small zone to the machine that will run acme-dns (records in your real DNS):
1 2auth.example.com. NS acme.example.com. acme.example.com. A 203.0.113.10acme-dns on that machine (
/etc/acme-dns/config.cfg), DNS on 53 and the API on 443:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15[general] listen = "0.0.0.0:53" protocol = "both" domain = "auth.example.com" nsname = "acme.example.com" nsadmin = "hostmaster.example.com" records = [ "auth.example.com. A 203.0.113.10", "auth.example.com. NS acme.example.com.", ] [api] ip = "0.0.0.0" port = "443" tls = "letsencrypt" disable_registration = falseRegister a client and create the CNAME:
1 2 3curl -s -X POST https://acme.example.com/register -H 'Content-Type: application/json' \ -d '{"allowfrom": ["203.0.113.0/24"]}' # {"username":"eabcdb41-…","password":"pbAX…","fulldomain":"d420c923-….auth.example.com","subdomain":"d420c923-…","allowfrom":["203.0.113.0/24"]}1_acme-challenge.example.com. CNAME d420c923-….auth.example.com.certbot with the hook:
1 2 3 4 5curl -fsSL -o /etc/letsencrypt/acme-dns-auth.py https://raw.githubusercontent.com/joohoi/acme-dns-certbot-joohoi/master/acme-dns-auth.py chmod +x /etc/letsencrypt/acme-dns-auth.py sed -i 's|^#!/usr/bin/env python$|#!/usr/bin/env python3|; s|^ACMEDNS_URL = .*|ACMEDNS_URL = "https://acme.example.com"|' /etc/letsencrypt/acme-dns-auth.py certbot certonly --manual --manual-auth-hook /etc/letsencrypt/acme-dns-auth.py \ --preferred-challenges dns --debug-challenges -d '*.example.com' -d example.comThe first time, the hook registers itself, stores the credentials in
/etc/letsencrypt/acmedns.jsonand pauses so you can create the CNAME it prints; Enter, and it validates. Renewals (certbot renew) never ask again.
Why
DNS-01 is the only challenge that gives wildcards and the only one that works for machines with no public port 80. The usual price is an API token from your DNS provider on every server that requests certificates; that token can typically change any record in the zone, and it sits in plain text under /etc/letsencrypt.
acme-dns turns that around: it is an authoritative DNS server for a zone of its own (auth.example.com) that only serves TXT records under random subdomains, with an API to update them. Since _acme-challenge.example.com is a CNAME to one of those subdomains, Let’s Encrypt follows the chain and validates the TXT there. Each client’s credentials only work for its subdomain; if stolen, the worst case is someone requesting a certificate for that name, not rewriting your zone.
cert-manager
The same scheme, with the credentials in a Secret:
| |
| |
| |
The JSON key is the certificate’s domain; *.example.com and example.com share the same entry (the _acme-challenge.example.com CNAME covers both). Every distinct domain needs its own registration and CNAME.
Traps
allowfromwith the IP acme-dns sees: the public egress IP of the server or the cluster, not the pod’s. If/updatefails with 401, it is this or the password.- CNAME TTL: keep it low (60–300 s) the first time; with a one-hour TTL, a mistake in the CNAME takes an hour to correct in Let’s Encrypt’s eyes. The TXT is served by acme-dns with TTL 1.
- A real port 53: acme-dns must be reachable over UDP and TCP from the Internet, and be the NS for
auth.example.comin your parent zone. Without the NS record there is no delegation and validation returns NXDOMAIN. - One registration per domain, not per server: if three servers request certificates for
example.com, they share the same registration and the sameacmedns.json, or each registers its own and the CNAME can only point to one. Share the credentials. --manualrenews fine because the hook is stored in/etc/letsencrypt/renewal/<domain>.conf; what you cannot do is change the hook without runningcertonlyagain.- Version: acme-dns 2.0.2 is from February 2025 and the project is still active; the certbot hook has not changed in years and works with Python 3 after fixing the shebang.
References: acme-dns, acme-dns-certbot-joohoi and ACMEDNS in cert-manager.
See also
- cert-manager: the HTTP-01 challenge returns 308 — when HTTP-01 is enough, and when it is not.
- HAProxy on pfSense with ACME and Cloudflare — DNS-01 with the provider’s token, the case acme-dns avoids.
- 2048-bit DKIM in a split TXT — another DNS record that breaks over details.