Return to Homepage

Certificates over DNS-01

ACME clients prove a name is yours over DNS-01 by setting a TXT record at _acme-challenge on it. It’s the only challenge that works for wildcard certificates, and for servers that aren’t reachable from the internet. Our endpoint speaks the acme-dns protocol, which most ACME clients support out of the box. The token goes straight into your domain’s DNS on our nameservers, so there’s no CNAME to set up, and it reaches every one of our nameservers in about a second.

Each name gets its own login, made on the domain’s DNS Management tab under Certificate Validation. A login can change only the TXT records at _acme-challenge on its name, nothing else on the account, so it’s safe to leave on a web server. A name covers its wildcard too: the login for example.com gets certificates for example.com and *.example.com. A certificate for several names (example.com and www.example.com) needs a login for each.

The login’s TXT records are kept for it: they can’t be changed from the DNS tab or the API until the name is removed from Certificate Validation. The two latest tokens are kept, so a wildcard and its apex can be validated in one order.

Update

POST https://acme.private-dns.com/update
Header Value
X-Api-User The login’s username
X-Api-Key The login’s password
json
{
  "subdomain": "the login's subdomain",
  "txt": "the 43-character token from the certificate authority"
}

Records are set with a 60-second TTL. There’s no /register: logins are made in the portal, not by the client.

Answers

Status Body Meaning
200 {"txt": "..."} The token is in DNS.
400 {"error": "bad_txt"} The token isn’t 43 letters, digits, - or _.
401 {"error": "forbidden"} Wrong username or password, or the subdomain isn’t this login’s.
403 {"error": "domain_not_active"} The domain isn’t active on the account.
429 {"error": "too_many_requests"} Too many requests, or too many wrong logins from your address. Wait for the Retry-After seconds.
503 {"error": "dns_error"} DNS refused the change or is briefly unavailable. Try again.

Setup

Replace USERNAME, PASSWORD, SUBDOMAIN and example.com with the name’s login. Use the server address exactly as shown, https://acme.private-dns.com, with no slash at the end: clients add /update to it themselves.

lego, Traefik, cert-manager and Caddy read logins from a JSON file keyed by name. Copy as JSON gives a name’s entry; for a certificate with several names, put each name’s entry in the one file. A wildcard uses its name’s entry (example.com for *.example.com).

json
{
  "example.com": {
    "username": "USERNAME",
    "password": "PASSWORD",
    "fulldomain": "_acme-challenge.example.com",
    "subdomain": "SUBDOMAIN",
    "server_url": "https://acme.private-dns.com",
    "allowfrom": []
  }
}

acme.sh

bash
export ACMEDNS_BASE_URL="https://acme.private-dns.com"
export ACMEDNS_USERNAME="USERNAME"
export ACMEDNS_PASSWORD="PASSWORD"
export ACMEDNS_SUBDOMAIN="SUBDOMAIN"
acme.sh --issue --server letsencrypt --dns dns_acmedns -d example.com -d '*.example.com'

acme.sh keeps the login with the certificate and uses it again on renewal. Its default certificate authority is ZeroSSL; --server letsencrypt picks Let’s Encrypt. acme.sh uses one login per certificate, so a certificate from it covers one name and its wildcard. Always set ACMEDNS_BASE_URL: without it, acme.sh sends the login to a public acme-dns server. If it asks you to create a CNAME, the variables weren’t exported.

lego

Save the JSON file, then:

bash
ACME_DNS_API_BASE=https://acme.private-dns.com \
ACME_DNS_STORAGE_PATH=/etc/lego/acme-dns.json \
lego --accept-tos --email you@example.com --dns acme-dns -d example.com -d '*.example.com' run

lego 5 takes the command first: lego run --accept-tos .... A name missing from the file makes lego try to register a new login, which fails with 404.

Traefik

Traefik uses lego. Mount the JSON file into the container and set ACME_DNS_API_BASE=https://acme.private-dns.com and ACME_DNS_STORAGE_PATH=/etc/traefik/acme-dns.json in its environment:

yaml
certificatesResolvers:
  letsencrypt:
    acme:
      email: you@example.com
      storage: /letsencrypt/acme.json
      dnsChallenge:
        provider: acme-dns

For a wildcard, give the router its names:

traefik.http.routers.app.tls.certresolver=letsencrypt
traefik.http.routers.app.tls.domains[0].main=example.com
traefik.http.routers.app.tls.domains[0].sans=*.example.com

Traefik reads the JSON file once, so restart it after adding a name.

cert-manager

Save the JSON file as acmedns.json and store it in a secret, in the cert-manager namespace for a ClusterIssuer (an Issuer’s own namespace otherwise):

bash
kubectl -n cert-manager create secret generic acme-dns --from-file=acmedns.json
yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: you@example.com
    privateKeySecretRef:
      name: letsencrypt-account-key
    solvers:
      - dns01:
          acmeDNS:
            host: https://acme.private-dns.com
            accountSecretRef:
              name: acme-dns
              key: acmedns.json

host needs the https://. Each name on a certificate needs its own entry in the file: one login can’t stand in for another name.

Caddy

Caddy needs the acme-dns module: xcaddy build --with github.com/caddy-dns/acmedns. For one name and its wildcard:

example.com, *.example.com {
	tls {
		dns acmedns {
			username USERNAME
			password {$ACMEDNS_PASSWORD}
			subdomain SUBDOMAIN
			server_url https://acme.private-dns.com
		}
	}
}

That form uses its one login for every name in the site, so for several names point it at the JSON file instead: dns acmedns /etc/caddy/acmedns.json.

certbot

certbot has no maintained acme-dns plugin that takes an existing login, so use a hook. Keep each name’s login in a file named after it, /etc/letsencrypt/acme-dns/example.com.env:

bash
ACMEDNS_USERNAME="USERNAME"
ACMEDNS_PASSWORD="PASSWORD"
ACMEDNS_SUBDOMAIN="SUBDOMAIN"

Then the hook, /etc/letsencrypt/acme-dns-hook.sh (make it executable):

bash
#!/bin/sh
set -eu
. "/etc/letsencrypt/acme-dns/${CERTBOT_DOMAIN}.env"
curl -fsS -X POST "https://acme.private-dns.com/update" \
  -H "X-Api-User: $ACMEDNS_USERNAME" -H "X-Api-Key: $ACMEDNS_PASSWORD" \
  -H "Content-Type: application/json" \
  --data "{\"subdomain\":\"$ACMEDNS_SUBDOMAIN\",\"txt\":\"$CERTBOT_VALIDATION\"}"
# certbot doesn't wait for DNS in this mode; ours updates in about a second
sleep 10
bash
certbot certonly --manual --preferred-challenges dns \
  --manual-auth-hook /etc/letsencrypt/acme-dns-hook.sh \
  -d example.com -d '*.example.com'

certbot passes example.com for both names, so one file serves the name and its wildcard. It saves the hook with the certificate, so certbot renew uses it again.

ZeroSSL and CAA

The same login works for ZeroSSL: only the client’s certificate authority setting changes. If the domain has CAA records, they must allow the certificate authority you use: letsencrypt.org for Let’s Encrypt, sectigo.com for ZeroSSL.

Snagged on something?

Our sysadmins answer tickets around the clock.