Let's Encrypt Wildcard Certificates with a Hidden Primary

Get wildcard certificates with the ACME DNS-01 challenge: certbot or acme.sh write the TXT record to your hidden primary with RFC 2136, and the secondary publishes it within seconds.

How DNS-01 works with a hidden primary

For a wildcard certificate such as *.example.ch, Let's Encrypt requires the DNS-01 challenge. Your ACME client creates a TXT record _acme-challenge.example.ch, and Let's Encrypt looks it up on your public nameservers. With a hidden primary, that works in four steps:

  1. The ACME client sends a signed dynamic update (RFC 2136) to your hidden primary.
  2. The primary adds the TXT record, raises the serial, and sends a NOTIFY.
  3. The secondary transfers the change and serves it on all public nameservers.
  4. Let's Encrypt queries the public nameservers and finds the record.

The ACME client never talks to the secondary. You need no API key at a DNS provider, only a TSIG key on your own primary.

1. Create a key for the ACME client

Use a separate key that may change only the challenge record, not a key that can transfer the zone.

terminal
$ tsig-keygen -a hmac-sha256 acme-update > /etc/bind/acme-update.key

2. Allow the update on the primary (BIND)

update-policy limits the key to TXT records at one name. With this rule, a stolen key cannot change anything else in your zone.

named.conf
include "/etc/bind/acme-update.key";
 
zone "example.ch" {
  type primary;
  file "/var/lib/bind/example.ch";
  update-policy {
    grant acme-update name _acme-challenge.example.ch. TXT;
  };
  // your existing transfer and notify settings stay as they are
};

BIND writes dynamic updates to a journal file next to the zone file, so the directory must be writable by BIND. That is why the example uses /var/lib/bind.

Important: once a zone accepts dynamic updates, rndc reload no longer reads a zone file you edited by hand, and it still reports success. For manual edits, use rndc freeze example.ch, edit the file, then rndc thaw example.ch.

3a. certbot

Install the RFC 2136 plugin (on Debian and Ubuntu: python3-certbot-dns-rfc2136) and create a credentials file:

/etc/letsencrypt/rfc2136.ini
# address of your hidden primary (an IP address, not a hostname)
dns_rfc2136_server = 192.0.2.1
dns_rfc2136_port = 53
dns_rfc2136_name = acme-update
dns_rfc2136_secret = <secret from acme-update.key>
dns_rfc2136_algorithm = HMAC-SHA256
terminal
$ chmod 600 /etc/letsencrypt/rfc2136.ini
$ certbot certonly --dns-rfc2136 \
  --dns-rfc2136-credentials /etc/letsencrypt/rfc2136.ini \
  --dns-rfc2136-propagation-seconds 60 \
  -d example.ch -d '*.example.ch'

3b. acme.sh

terminal
$ export NSUPDATE_SERVER="192.0.2.1"
$ export NSUPDATE_KEY="/etc/bind/acme-update.key"
$ export NSUPDATE_ZONE="example.ch"
$ acme.sh --issue --dns dns_nsupdate --dnssleep 60 \
  -d example.ch -d '*.example.ch'

acme.sh saves these values and uses them again for every renewal.

Why wait 60 seconds

Let's Encrypt checks the record from several places and may ask any of your public nameservers. The record has to be on all of them before the check starts. With NOTIFY, a secondary usually has the change within a few seconds; the 60 seconds are a safety margin. If a validation fails with "no TXT record found", check that your primary sends NOTIFY to the secondary. Without it, the secondary only sees the change at the next refresh.

Check it yourself while the challenge is running:

terminal
$ dig +short TXT _acme-challenge.example.ch @ns1.hiddenprimary.ch

Option: a separate zone only for challenges

If you would rather not make your main zone dynamic, point the challenge name to a small zone that exists only for this purpose. Let's Encrypt follows the CNAME:

example.ch zone
_acme-challenge.example.ch. IN CNAME _acme-challenge.acme.example.ch.

Make acme.example.ch its own zone with the update-policy rule. Add it on your secondary like any other zone, and delegate it: put NS records for acme.example.ch into example.ch that name the secondary's nameservers. Then point certbot or acme.sh at acme.example.ch. Your main zone stays static, and you edit it as before.

Next Steps