DNS ACME Challenges with BIND 9

Sven Haardiek, 2025-11-15

Update (2026-07-16): I originally ran this setup with a single DNS server. That turned out to be a problem: my registrar (like most TLDs) expects at least two nameservers on different IP addresses, and a single server is of course also a single point of failure — if it goes down, name resolution and certificate renewal break with it. So I added a primary + secondary DNS setup for redundancy, using free secondaries from Hetzner. The new Adding redundancy with secondary DNS section describes all of this, and the example IPs throughout the post have been updated to my current server.

For my homelab I always used Caddy as my reverse Proxy and because its not directly accessible from the internet, I decided to use ACME DNS Challenges with Cloudflare for my Let’s Encrypt certificates. But since lately, I decided to try to rely less on big tech companies and do more stuff on my own. I tried a couple things like for example acme-dns, but it always felt a little strange to me that all those solution invent new APIs for this and everybody has to implement them for Cloudflare, acme-dns and all the other APIs out there. At that point I found out that there is RFC2136 which describes dynamic DNS Updates directly via an DNS Request and also found out that there is an Caddy Plugin for this.

So I decided to say goodbye to Cloudflare and manage my own DNS server and use dynamic DNS Update for certificates. This setup, I want to describe here. It has two parts really: running your own authoritative DNS with BIND 9, and using it for ACME DNS-01 challenges via RFC2136. Maybe you also want to use less big tech.

Prerequisites

First of all, since others want to connect to your DNS server or at least Let’s Encrypt wants to for the challenges, you need to have a server which is accessable from the Internet directly via IP. I decided to rent a pretty small and cheap VM for this by netcup and installed Debian Trixie, but you do you.

Also I decided to use BIND 9 as my DNS server. As far as I am aware, there are multiple different DNS server which support dynamic DNS Updates, like Knot DNS or PowerDNS, but honestly, the main reason for me to use BIND 9 was that I already know about it and did not really care after the setup was done.

Also I needed to compile Caddy on my own which is pretty easy with xcaddy. Since I wanted to deploy Caddy in a Container anyway, I just created my own image with, like this:

FROM caddy:builder AS builder

RUN xcaddy build \
    --with github.com/caddy-dns/rfc2136

FROM caddy:latest
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

Configuring BIND 9

Now we need to configure our own DNS server. I will do this as briefly as possible and not bother you with details of my own BIND 9 configuration like DNSSec or IPv6. Also I will do this exemplary with my own domain haardiek.org.

For the overview, there are two relevant directories for BIND 9 in Debian Trixie /etc/bind for static configuration and /var/lib/bind for dynamic files.

First of all we need a way to make sure that we are the only ones able to update DNS entries in our zone, this is done by cryptographically signing the DNS messages we are going to send for updates. For this we need a TSIG Key, which can be generated and stored like this:

tsig-keygen haardiek.org > /etc/bind/key.haardiek.org

Note that tsig-keygen without an explicit algorithm gives you hmac-sha256 these days, which is fine — just remember the value, because Caddy needs to match it later.

After that, we can add our generated key and our zone to /etc/bind/named.conf.local, which is meant to be for your own configuration in Debian. It should look something like this:

# /etc/bind/named.conf.local

# include the update key
include "/etc/bind/key.haardiek.org";

# define the zone
zone "haardiek.org" {
    type master;
    # dynamic zone file
    file "/var/lib/bind/db.haardiek.org";
    allow-transfer { none; };
    # allow updates with the key
    allow-update { key "haardiek.org"; };
};

The most complicated part for me was now, that I wanted to have static DNS entries for all of my server I can manage via a configuration file (I deploy them with Ansible) and also dynamic entries managed from Caddy for the DNS challenges in the same zone haardiek.org. This is something which is not supported by BIND 9 DNS. With dynamic updates, BIND 9 stores all changes in a journal files /var/lib/bind/*.jnl and plays them back to the zones files. If you change the zone file manually, the journal does not fit anymore and an error will occur, when reloading BIND 9. You can work around this by with the included tooling, see here, but this also means to block new dynamic updates for some times and seems clumsy for me to implement in any automation.

The easiest way for me to work around this, was to not handle my own DNS entries in a zone file, but also use the dynamic update mechanism, I already provide for Caddy. There are some amazing tools out there to do this like octoDNS where you can define all our DNS entries via YAML and sync it idempotently to your zone and maybe in the future I will do that, but currently I do not have that many entries and decided to simply use nsupdate. nsupdate lets you submit your dynamic DNS requests directly from a small file, but it is not as comfortable, because we also need to handle the idempotency ourselves.

So I created a small zone file /var/lib/bind/db.haardiek.org as a starting point for the zone with:

$TTL 300
@ IN SOA ns1.haardiek.org. admin.haardiek.org. (
        1          ; Serial
        3600       ; Refresh
        1800       ; Retry
        604800     ; Expire
        300        ; Minimum TTL
)

;; NS Records
@        IN NS ns1.haardiek.org.

;; A Records
ns1      IN A 193.31.24.232

;; AAAA Records
ns1      IN AAAA 2a03:4000:2b:1875:68c2:fcff:fec7:f8af

And created my my small nsupdate script /etc/bind/nsupdate.haardiek.org (I shortened it a bit for this):

server 127.0.0.1
zone haardiek.org

update delete ns1.haardiek.org A
update add ns1.haardiek.org 300 A 193.31.24.232

update delete ns1.haardiek.org AAAA
update add ns1.haardiek.org 300 AAAA 2a03:4000:2b:1875:68c2:fcff:fec7:f8af

update delete blog.haardiek.org CNAME
update add blog.haardiek.org 300 CNAME shaardie.github.io.

update delete haardiek.org MX
update add haardiek.org 300 MX 10 mxext1.mailbox.org.
update add haardiek.org 300 MX 10 mxext2.mailbox.org.
update add haardiek.org 300 MX 20 mxext3.mailbox.org.

show

send

A small but nice property here: update delete <name> <type> does not fail if the record does not exist yet. That is exactly what makes this delete-then-add pattern idempotent — I can run the same script over and over and always end up in the same state.

And after reloading the server with systemctl reload bind9 to make BIND 9 aware of the new zone, I was able to add my own static entries with:

nsupdate -k "/etc/bind/key.haardiek.org" "/etc/bind/nsupdate.haardiek.org"

As said, I do all this with Ansible, so the regular steps for installing or updating for me are:

And that’s it.

Adding redundancy with secondary DNS

Now, if you have been paying attention, you might have spotted a rather large problem: everything so far runs on a single DNS server. If that one VM goes down, name resolution for my whole domain goes with it — and, just as bad, I would not be able to renew any certificates either.

On top of that, most TLDs (and my registrar) expect at least two nameservers on different IP addresses. Two NS records pointing at the same box do not count anymore.

The classic answer to this is a primary with secondaries: my BIND 9 stays the authoritative primary where I actually edit the zone, and one or more secondary servers pull a read-only copy via zone transfer (AXFR/IXFR). Whenever I change something, the primary sends a NOTIFY and the secondaries fetch the updated zone.

For the secondaries I did not want to run more servers myself, so I went with a free secondary DNS service. Hetzner offers this for free and gives you three nameservers on distinct IPs, which is more than enough to satisfy the “two different nameservers” requirement. Other options like Hurricane Electric exist too.

To make this work, the zone needs to allow transfers to the secondaries and notify them about changes:

# /etc/bind/named.conf.local

# the Hetzner secondary nameservers (verify current IPs in their docs!)
acl hetzner-secondaries {
    213.239.242.238;
    2a01:4f8:0:a101::a:1;
    213.133.100.103;
    2a01:4f8:0:1::5ddc:2;
    193.47.99.3;
    2001:67c:192c::add:a3;
};

remote-servers hetzner-notify {
    213.239.242.238;
    2a01:4f8:0:a101::a:1;
    213.133.100.103;
    2a01:4f8:0:1::5ddc:2;
    193.47.99.3;
    2001:67c:192c::add:a3;
};

zone "haardiek.org" {
    type master;
    file "/var/lib/bind/db.haardiek.org";

    allow-transfer { hetzner-secondaries; };
    also-notify   { hetzner-notify; };

    allow-update  { key "haardiek.org"; };
};

A little gotcha that cost me a moment: allow-transfer takes an address match list (so an acl works), but also-notify wants a remote-servers list — you cannot hand it an acl. Hence the two nearly identical blocks above.

You also need to add the secondaries to your NS record set and, of course, register all nameservers at your registrar. Because ns1.haardiek.org lives inside the very domain it serves (it is in-bailiwick), it needs a glue record — the registrar has to store its IP address directly, otherwise nobody can resolve it (a classic chicken-and-egg problem). The Hetzner nameservers do not need glue, since they live in .de/.com and are resolved there already.

Once everything is in place, I like to verify that nameservers actually serve the same zone by comparing the SOA serial — the serial is the source of truth here:

dig @ns1.first-ns.de haardiek.org SOA

Securing the transfer with TSIG (optional)

The config above restricts zone transfers by IP address, which is a reasonable start. But an IP allow-list can in theory be spoofed, and a plain AXFR lets anyone who reaches it dump your entire zone — a nice map of all your hosts for an attacker. The cleaner way is to authenticate transfers with TSIG, the same shared-secret mechanism we already use for dynamic updates.

Configuring Caddy

The Caddy configuration is even more simple.

Like described in The RFC2136 DNS Provider Plugin, I created a configuration file with the information about the dns server and the TSIG key in /etc/caddy/nsupdate.caddy:

{
    acme_dns rfc2136 {
        key_name "haardiek.org"
        key_alg "hmac-sha256"
        key "XXX"
        server "ns1.haardiek.org:53"
    }
}

and referenced it in my Caddyfile with import /etc/caddy/nsupdate.caddy. After that DNS Challenges against my own server were automatically used if no other configuration was present.

There is one subtlety worth knowing once you run secondaries: Let’s Encrypt may query any of your authoritative nameservers when validating a challenge, so the _acme-challenge TXT record has to be visible on all of them before validation kicks in. Since the primary sends a NOTIFY on every update and the secondaries pull the change within a second or two, this usually just works — but if you ever see flaky validations, give the ACME client a small propagation delay so it waits for the record to show up everywhere before asking Let’s Encrypt to check.

So creating a reverse proxy entry with proper TLS is now as easy as:

kuma.internal.haardiek.org {
  reverse_proxy kuma-uptime-kuma-1:3001
}

Conclusion

So that’s my setup and I am pretty happy about because it reduces my reliance on big tech companies at least a bit.

Setting is up was a bit fiddly at the beginning and the big DNS servers are not the most user friendly, but with a primary and a couple of free secondaries for redundancy, I think this solution is quite solid now.


Note: The 2026 update to this post was drafted with the help of an AI assistant. All content reflects my own setup and decisions, and everything here was reviewed and tested manually by me before publishing.