TL;DR

  1. Delegate a small zone to the machine that will run acme-dns (records in your real DNS):

    1
    2
    
    auth.example.com.   NS   acme.example.com.
    acme.example.com.   A    203.0.113.10
    
  2. acme-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 = false
    
  3. Register a client and create the CNAME:

    1
    2
    3
    
    curl -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.
    
  4. certbot with the hook:

    1
    2
    3
    4
    5
    
    curl -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.com
    

    The first time, the hook registers itself, stores the credentials in /etc/letsencrypt/acmedns.json and 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:

1
kubectl -n cert-manager create secret generic acme-dns --from-file=acmedns.json=./acmedns.json
1
2
3
4
5
6
7
8
9
{
  "example.com": {
    "username": "eabcdb41-d89f-4580-826f-3e62e9755ef2",
    "password": "<password from the registration>",
    "fulldomain": "d420c923-bbd7-4056-ab64-c3ca54c9b3cf.auth.example.com",
    "subdomain": "d420c923-bbd7-4056-ab64-c3ca54c9b3cf",
    "allowfrom": ["203.0.113.0/24"]
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-dns
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@example.com
    privateKeySecretRef:
      name: letsencrypt-dns-account-key
    solvers:
    - dns01:
        acmeDNS:
          host: https://acme.example.com
          accountSecretRef:
            name: acme-dns
            key: acmedns.json

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

  • allowfrom with the IP acme-dns sees: the public egress IP of the server or the cluster, not the pod’s. If /update fails 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.com in 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 same acmedns.json, or each registers its own and the CNAME can only point to one. Share the credentials.
  • --manual renews fine because the hook is stored in /etc/letsencrypt/renewal/<domain>.conf; what you cannot do is change the hook without running certonly again.
  • 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