En corto

  1. Delega una zona pequeña a la máquina donde correrá acme-dns (registros en tu DNS real):

    1
    2
    
    auth.example.com.   NS   acme.example.com.
    acme.example.com.   A    203.0.113.10
    
  2. acme-dns en esa máquina (/etc/acme-dns/config.cfg), escuchando DNS en el 53 y la API en el 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. Registra un cliente y crea el 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 con el 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
    

    La primera vez el hook se registra solo, guarda las credenciales en /etc/letsencrypt/acmedns.json y se para para que crees el CNAME que imprime; Enter, y valida. Las renovaciones (certbot renew) ya no preguntan nada.

Por qué

DNS-01 es el único reto que da wildcards y el único que funciona para máquinas sin puerto 80 público. El precio habitual es un token de API de tu proveedor DNS en cada servidor que pide certificados; ese token normalmente puede cambiar cualquier registro de la zona, y vive en texto plano en /etc/letsencrypt.

acme-dns le da la vuelta: es un servidor DNS autoritativo de una zona propia (auth.example.com) que solo sabe servir registros TXT bajo subdominios aleatorios, con una API para actualizarlos. Como _acme-challenge.example.com es un CNAME hacia uno de esos subdominios, Let’s Encrypt sigue la cadena y valida el TXT allí. Las credenciales de cada cliente solo sirven para su subdominio; robadas, lo peor que pasa es que alguien pida un certificado para ese nombre, no que reescriba tu zona.

cert-manager

El mismo esquema, con las credenciales en un 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 del registro>",
    "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

La clave del JSON es el dominio del certificado; para *.example.com y example.com vale la misma entrada (el CNAME _acme-challenge.example.com cubre ambos). Cada dominio distinto necesita su registro y su CNAME.

Trampas

  • allowfrom con la IP que ve acme-dns: la pública de salida del servidor o del clúster, no la del pod. Si falla el /update con 401, es esto o la contraseña.
  • TTL del CNAME: ponlo bajo (60–300 s) la primera vez; con un TTL de una hora, un error en el CNAME tarda una hora en corregirse a ojos de Let’s Encrypt. El TXT lo sirve acme-dns con TTL 1.
  • Puerto 53 de verdad: acme-dns tiene que ser alcanzable por UDP y TCP desde Internet, y ser el NS de auth.example.com en tu zona padre. Sin el registro NS, la delegación no existe y la validación devuelve NXDOMAIN.
  • Un registro por dominio, no por servidor: si tres servidores piden certificados para example.com, comparten el mismo registro y el mismo acmedns.json, o cada uno registra el suyo y el CNAME solo puede apuntar a uno. Comparte las credenciales.
  • --manual renueva bien porque el hook está guardado en /etc/letsencrypt/renewal/<dominio>.conf; lo que no puedes es cambiar el hook sin volver a ejecutar certonly.
  • Versión: acme-dns 2.0.2 es de febrero de 2025 y el proyecto sigue activo; el hook de certbot no ha cambiado en años y funciona con Python 3 tras corregir el shebang.

Referencias: acme-dns, acme-dns-certbot-joohoi y ACMEDNS en cert-manager.

Ver también