TL;DR: The silent failure mode is the one that gets you. Your router's built-in resolver — whatever dnsmasq stub is baked into your OpenWrt or consumer firmware — handles `nas.
📖 Reading time: ~20 min
What's in this article
- The Problem That Makes Local DNS Worth Running
- What Each Tool Actually Is (And Isn't)
- Setting Up Bind9: Authoritative Server for Internal Zones
- Setting Up PowerDNS: When You Want an API and a Database Backend
- Bind9 vs PowerDNS: The Honest Operator Comparison
- Running Either One in Docker Without Losing Your Mind
- What to Point Your Clients At and How to Test It
The Problem That Makes Local DNS Worth Running
The silent failure mode is the one that gets you. Your router's built-in resolver — whatever dnsmasq stub is baked into your OpenWrt or consumer firmware — handles nas.local and homeassistant.local just fine with two devices. Add Nginx Proxy Manager routing to five services behind a single IP, throw in a Traefik instance for containers, and you'll start hitting a specific class of breakage: the hostname resolves, but to the wrong thing, or resolves on some devices and not others, with zero error messages to tell you why. The router just silently returns whatever it last cached or makes a best-guess mDNS query that works on macOS and fails on Linux.
The /etc/hosts approach feels like a fix but compounds the problem. You add 192.168.1.50 grafana.home.lab to your laptop, forget to add it to your phone, your n8n container can't resolve it at all because it's running in Docker with its own DNS context, and now you're SSHing into three different machines to update flat files every time a service moves IPs. Reverse proxies specifically depend on stable FQDNs because that's how virtual hosting works — Nginx Proxy Manager matches Host: headers to upstream definitions, and if half your clients are hitting an IP directly or resolving a stale hosts entry, the proxy rule never fires. Traefik has the same dependency; its routing rules are built around hostnames, not IPs.
What a real local resolver actually gives you breaks down into three concrete things. First, a single authoritative source for your internal zone — change an IP in one place and every device on the network picks it up on next TTL expiry. Second, wildcard records: a single *.home.lab A 192.168.1.50 entry means every subdomain you spin up under that zone resolves immediately without touching DNS config again. Third, query latency that doesn't leave your LAN — a bind9 or PowerDNS instance answering from its local zone file returns under 1ms, versus 20-80ms for an upstream query to your ISP or even a local Pi-hole forwarding upstream. For webhook delivery in automation pipelines, that latency difference is irrelevant, but the reliability difference isn't: a misconfigured or unavailable upstream resolver kills webhook callbacks silently.
Split-horizon DNS is where this gets genuinely useful beyond just convenience. The idea is that grafana.home.lab resolves to 192.168.1.50 from inside your network, and either doesn't resolve at all or resolves to a public IP from outside — depending on whether you're also publishing that subdomain publicly. Your router's resolver cannot do this. It has one answer for a name, and that answer is whatever it learned from upstream or from its own static config. A proper authoritative DNS server for your internal zone handles split-horizon natively because it's authoritative for a zone that simply doesn't exist in the public DNS tree. If your home lab is growing toward full automation pipelines, see the guide on Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines — webhook delivery specifically requires that your internal services resolve consistently from wherever the webhook sender lives, which is a DNS problem before it's anything else.
What Each Tool Actually Is (And Isn't)
The single distinction that trips up almost every beginner: authoritative versus recursive DNS are completely different jobs, and neither Bind9 nor PowerDNS does both by default. An authoritative server answers "I own this zone, here's the record." A recursive resolver answers "let me go find that for you." Conflating the two is why home lab setups break in confusing ways — your server responds to dig queries about your own domain but silently refuses everything else.
Bind9
Bind9 is the reference implementation of the DNS protocol — when the RFCs describe how DNS is supposed to behave, Bind9 is usually what they're testing against. It runs as a single daemon (named), reads zone files in the RFC-standard text format (one record per line, SOA at the top, trailing dots required), and by default does nothing but serve those zones authoritatively. Version 9.18.x, which ships in Ubuntu 22.04's standard repos, added significantly better structured logging compared to older 9.16.x builds — you get category-level log channels out of the box instead of a wall of undifferentiated output. The zone file format is unambiguous and version-controllable with plain git, which is its real advantage for home use. The downside: editing zone files by hand means manually incrementing the SOA serial number every time, and forgetting that will cause secondaries to silently ignore your changes.
PowerDNS
PowerDNS ships as two completely separate binaries that do not share configuration files or processes: pdns (the authoritative server) and pdns_recursor. You can run one without the other. The authoritative server's default backend is a SQL database — SQLite for single-node setups, PostgreSQL or MySQL for anything you care about. That database dependency is real overhead, but it means you can update DNS records with a single SQL statement or a REST API call instead of editing a text file, incrementing a serial, and reloading a daemon. For automation — feeding records from a provisioning script or n8n flow — this is a significant practical advantage. The Recursor is a separate project on a separate release cadence; as of this guide, PowerDNS Authoritative is at 4.8.x and pdns_recursor is at 5.x. Do not assume the version numbers track each other.
What Neither Tool Is
These are the things beginners reach for Bind9 or PowerDNS to do, and shouldn't:
- DHCP server — that's
dnsmasqor Kea. Both Bind9 and PowerDNS will answer DNS queries; neither assigns IP addresses. If you want DNS records to update automatically when a device gets a DHCP lease, you need a separate integration layer. - DNS-over-HTTPS or DNS-over-TLS termination — that's
dnsdist(which is actually a PowerDNS project) or a stub resolver like Unbound with TLS wrapping. Neitherpdnsnornamedspeaks DoH natively in their standard configurations. - Firewall-level ad blocking — that's Pi-hole, which is a web UI layered on top of dnsmasq (or FTL, their fork of it). Pi-hole blocks ads by returning NXDOMAIN or a null IP for blocklisted domains at the resolver level. Bind9 and PowerDNS can technically be configured to do something similar with RPZ (Response Policy Zones), but that's a significantly more complex setup and not what this guide covers.
Keeping these boundaries clear matters before you pick a tool. If your goal is a local authoritative server for home.lab with predictable, version-controlled zone data, Bind9 is the lower-friction path. If your goal is automation — records managed by scripts, a REST API, or a database you already operate — the PowerDNS authoritative server's SQL backend earns its extra dependency.
Setting Up Bind9: Authoritative Server for Internal Zones
The part that surprises most people setting up Bind9 for the first time: the default package configuration on Debian/Ubuntu ships with recursion enabled for any querying host. That's fine on a locked-down machine with a firewall, but the correct fix isn't to rely on firewall rules — it's to configure Bind9 to not do recursion at all on an authoritative-only server. Open recursive resolvers on home networks get abused for DNS amplification attacks. Lock it down at the application layer first.
`shell
Install bind9 and the companion utilities (dig, named-checkconf, etc.)
apt install bind9 bind9utils bind9-doc
/etc/bind/named.conf.options
options {
directory "/var/cache/bind";
# Only listen on your LAN interface and loopback — not 0.0.0.0
listen-on { 192.168.1.0/24; 127.0.0.1; };
# This server is authoritative-only. No recursion, no forwarding.
allow-recursion { none; };
recursion no;
# Don't answer queries from outside your LAN
allow-query { 192.168.1.0/24; 127.0.0.1; };
dnssec-validation auto;
};
`
After locking down the options, wire up your internal zone in /etc/bind/named.conf.local. The zone declaration just tells Bind9 where to find the zone file — the actual records live separately.
`conf
/etc/bind/named.conf.local
zone "home.lab" {
type master;
file "/etc/bind/db.home.lab";
};
Reverse zone for 192.168.1.x — the name format is counterintuitive
zone "1.168.192.in-addr.arpa" {
type master;
file "/etc/bind/db.192.168.1";
};
`
Now the zone file itself. The trailing dot on FQDNs is the single most common silent failure in Bind9 setups. If you write ns1.home.lab without a trailing dot inside a zone file, Bind9 appends the zone origin, giving you ns1.home.lab.home.lab. Named-checkzone won't always flag this as an error — it's syntactically valid, just wrong. You'll see successful zone loads and completely broken resolution. Add the dot, every time, on any name that's fully qualified.
`plaintext
/etc/bind/db.home.lab
$TTL 3600
@ IN SOA ns1.home.lab. admin.home.lab. (
2024010101 ; Serial — increment this on every edit
3600 ; Refresh
900 ; Retry
604800 ; Expire
300 ) ; Negative TTL
; NS record — trailing dot required on the FQDN
@ IN NS ns1.home.lab.
; A records for your infrastructure
ns1 IN A 192.168.1.10
router IN A 192.168.1.1
nas IN A 192.168.1.20
n8n IN A 192.168.1.30
ollama IN A 192.168.1.32
`
`plaintext
/etc/bind/db.192.168.1
$TTL 3600
@ IN SOA ns1.home.lab. admin.home.lab. (
2024010101
3600
900
604800
300 )
@ IN NS ns1.home.lab.
; PTR records — left side is just the last octet
10 IN PTR ns1.home.lab.
1 IN PTR router.home.lab.
20 IN PTR nas.home.lab.
30 IN PTR n8n.home.lab.
32 IN PTR ollama.home.lab.
`
PTR records aren't optional decoration. SSH builds its known_hosts entries using reverse DNS when it can resolve them, and several Docker healthcheck scripts do reverse lookups to verify they're talking to the right host. Missing PTR records cause subtle, hard-to-diagnose failures that show up weeks after initial setup — especially if you're running anything that validates hostnames against IPs.
Test everything before touching your router's DNS setting. The distinction between SERVFAIL and NXDOMAIN matters here: NXDOMAIN means "zone loaded fine, record doesn't exist"; SERVFAIL means "something is broken in the zone itself." If you're getting SERVFAIL on a name you just added, the zone file has a syntax error and Bind9 is serving stale data or nothing at all.
`shell
Validate config syntax first — exits non-zero on any error
named-checkconf
Validate the zone file specifically
named-checkzone home.lab /etc/bind/db.home.lab
Expected output: zone home.lab/IN: loaded serial 2024010101
OK
named-checkzone 1.168.192.in-addr.arpa /etc/bind/db.192.168.1
Reload after a clean check — don't restart, reload preserves state
systemctl reload bind9
Forward lookup — expect NOERROR + an A record in the ANSWER section
dig @127.0.0.1 ollama.home.lab A
Reverse lookup — expect NOERROR + PTR record
dig @127.0.0.1 -x 192.168.1.32
If you get SERVFAIL here, check: journalctl -u named | tail -20
Look for "zone home.lab/IN: loading from master file failed"
`
Setting Up PowerDNS: When You Want an API and a Database Backend
The thing that surprises most people about PowerDNS is that it ships as two completely separate binaries — pdns_server (the authoritative server) and pdns_recursor — and you almost always need both running together for a home lab setup to actually work. Get one wrong and clients either can't resolve internal names or get REFUSED for anything outside your domain. That split architecture is worth understanding before you touch a config file.
Start with just the authoritative server and the SQLite3 backend. For a home lab with a handful of zones, there's no reason to spin up PostgreSQL:
`shell
apt install pdns-server pdns-backend-sqlite3
Initialize the schema — PowerDNS ships the SQL file at this path
sqlite3 /var/lib/powerdns/pdns.sqlite3 < /usr/share/doc/pdns-backend-sqlite3/schema.sqlite3.sql
`
Then edit /etc/powerdns/pdns.conf — the default file is heavily commented, which helps. The minimum working config for an API-enabled authoritative server looks like this:
`conf
/etc/powerdns/pdns.conf
launch=gsqlite3
gsqlite3-database=/var/lib/powerdns/pdns.sqlite3
Run on a non-standard port so pdns-recursor can sit in front on :53
local-port=5300
local-address=127.0.0.1
Enable the REST API
api=yes
api-key=yoursecretkey
webserver=yes
webserver-address=127.0.0.1
webserver-port=8081
webserver-allow-from=127.0.0.1
`
Once pdns_server is running, you can create a zone without touching any flat files. This is the actual architectural difference from Bind9 — everything goes through the API or the database, not a text file you reload:
`shell
Create a zone
curl -s -X POST http://localhost:8081/api/v1/servers/localhost/zones \
-H "X-API-Key: yoursecretkey" \
-H "Content-Type: application/json" \
-d '{
"name": "home.arpa.",
"kind": "Native",
"nameservers": ["ns1.home.arpa."]
}'
Add an A record
curl -s -X PATCH http://localhost:8081/api/v1/servers/localhost/zones/home.arpa. \
-H "X-API-Key: yoursecretkey" \
-H "Content-Type: application/json" \
-d '{
"rrsets": [{
"name": "router.home.arpa.",
"type": "A",
"ttl": 300,
"changetype": "REPLACE",
"records": [{"content": "192.168.1.1", "disabled": false}]
}]
}'
`
Now install pdns-recursor as the second process. This one listens on port 53 and handles all upstream recursion — Google, Cloudflare, your ISP — while forwarding your internal zones back to the authoritative server running on 127.0.0.1:5300. Edit /etc/powerdns/recursor.conf:
`conf
/etc/powerdns/recursor.conf
local-address=0.0.0.0
local-port=53
Forward internal zone to the authoritative server
forward-zones=home.arpa.=127.0.0.1:5300
Use upstream resolvers for everything else
forward-zones-recurse=.=1.1.1.1;8.8.8.8
`
The gotcha that burns everyone at least once: the PowerDNS authoritative server will flat-out refuse recursive queries. Point a client directly at port 5300 and ask for google.com — you get REFUSED, not a timeout, not a forwarded answer. REFUSED. This is correct behavior per DNS spec, but it looks like a misconfiguration the first time you see it. The fix is always the same: your clients and your DHCP server should point at the recursor on port 53, never directly at the authoritative server. The authoritative server is an internal implementation detail. If you're also running dnsmasq for DHCP, you can point its upstream at the recursor instead of removing dnsmasq entirely — they compose fine as long as port 53 isn't double-bound.
Bind9 vs PowerDNS: The Honest Operator Comparison
The most useful thing I can tell you upfront: these two servers solve the same core problem with completely different philosophies, and the right choice becomes obvious once you know which direction your setup is heading.
Bind9 to a first working zone is genuinely about 20 minutes. You edit named.conf.local to declare the zone, write the zone file itself, run named-checkzone to verify syntax, and reload:
`conf
/etc/bind/named.conf.local
zone "home.lab" {
type master;
file "/etc/bind/zones/db.home.lab";
};
`
`shell
verify before reloading — catches silent syntax errors
named-checkzone home.lab /etc/bind/zones/db.home.lab
reload without restarting (preserves cache)
rndc reload
`
Two files, one command. PowerDNS's equivalent starting point involves picking a backend (SQLite is the right answer for a single-operator setup), initializing the schema, configuring pdns.conf with the backend path and API key, then understanding that PowerDNS splits authoritative serving and recursive resolution into two separate binaries — pdns_server and pdns_recursor — that need to be configured to talk to each other on different ports. None of this is hard, but it's not 20 minutes either. If you want stable internal DNS and don't need automation, Bind9 is the pragmatic choice.
The calculus flips the moment you want to programmatically register records. Every time a new Docker container comes up, or an n8n webhook endpoint gets created, you do not want to be sed-ing a zone file and calling rndc reload. That path breaks on concurrent writes, requires root or sudo for file edits, and the error surface is wide. A PowerDNS API call is atomic and purpose-built:
`shell
add an A record via PowerDNS REST API — no file manipulation, no reload
curl -s -X PATCH http://localhost:8081/api/v1/servers/localhost/zones/home.lab. \
-H "X-API-Key: your-api-key-here" \
-H "Content-Type: application/json" \
-d '{
"rrsets": [{
"name": "new-container.home.lab.",
"type": "A",
"ttl": 60,
"changetype": "REPLACE",
"records": [{ "content": "192.168.1.45", "disabled": false }]
}]
}'
`
That call returns immediately, the record is live, and you can call it from an n8n HTTP Request node or a Node.js script without touching the filesystem. Bind9 does have nsupdate for dynamic updates via DNS protocol, but it requires TSIG key setup and the tooling is significantly more awkward than a JSON POST.
On resource footprint: Bind9 with a single zone idles around 20–30 MB RSS. PowerDNS authoritative plus recursor plus SQLite backend runs closer to 60–80 MB total across both processes. On any machine already running Docker, this is completely irrelevant — Docker's own overhead dwarfs it. Where it actually matters is a constrained device like a Pi Zero 2W, where you're budgeting memory carefully and a single lean Bind9 process is the cleaner fit.
Factor
Bind9
PowerDNS
Setup to first working zone
~20 min
~40 min
Backend
Flat zone files
SQLite / PostgreSQL / MySQL
REST API
No (rndc + nsupdate only)
Yes, built-in
Recursion
Yes, same process with config
Separate binary (pdns_recursor)
Idle memory (single zone)
~20–30 MB
~60–80 MB (both binaries)
Best for
Static internal zones, fast setup
Dynamic records, scripted automation
Running Either One in Docker Without Losing Your Mind
The single biggest reason DNS containers fail to start has nothing to do with your config files. On Ubuntu 22.04 and Debian 12, systemd-resolved runs a stub listener on 127.0.0.53:53 before your container even tries to bind. You'll see bind: address already in use and spend an hour re-reading your named.conf looking for a typo that isn't there. Fix this first, before you pull any image:
`shell
Stop and permanently disable the stub resolver
sudo systemctl disable --now systemd-resolved
Remove the symlink resolv.conf and write a real one
sudo rm /etc/resolv.conf
echo "nameserver 192.168.1.10" | sudo tee /etc/resolv.conf
Replace 192.168.1.10 with the LAN IP of your Docker host —
that's where your new DNS container will answer queries
`
After that, verify nothing is still holding port 53 with sudo ss -tulpn | grep ':53'. The output should be empty. Only then does it make sense to bring up a DNS container — otherwise you're just restarting a container that will immediately fail with the same error and a different service name in the log.
For Bind9, the internetsystemsconsortium/bind9:9.18 image is the one to use — it's maintained by ISC, ships with a predictable directory layout, and doesn't include distro patches that quietly change default behavior. The critical networking decision is do not use bridge networking with NAT for a DNS server. ACLs based on source IP break when Docker's NAT layer rewrites the client address. Use network_mode: host, or map ports explicitly and accept that allow-query will see your host IP, not the actual client. Here's a working compose file:
yaml
version: "3.9"
services:
bind9:
image: internetsystemsconsortium/bind9:9.18
container_name: bind9
network_mode: host # preserves real client IPs for ACL matching
restart: unless-stopped
volumes:
- ./config/named.conf:/etc/bind/named.conf:ro
- ./config/zones:/etc/bind/zones:ro
- bind9-cache:/var/cache/bind
# No ports: block needed — host mode handles it
volumes:
bind9-cache:
Your named.conf and zone files live in ./config/ relative to the compose file, mounted read-only. The cache volume is a named Docker volume so query caches survive container restarts without cluttering your host filesystem. One gotcha: Bind9 inside the container runs as the bind user (UID 101 on that image), so if you get permission errors on your zone files, chmod 644 on the files and confirm the mount path matches exactly — a trailing slash difference will cause a silent empty mount.
PowerDNS in Docker has a separate problem: the SQLite backend needs a database file that actually persists, and the default socket directory inside the container is /var/run/pdns, not /run/pdns like the Debian package assumes. If you mount a pdns.conf written for a bare-metal install without adjusting those paths, the control socket won't open and pdns_control commands will fail silently. Mount a named volume for the database and a custom config that corrects both paths:
yaml
version: "3.9"
services:
powerdns:
image: powerdns/pdns-auth-48:4.8.3
container_name: powerdns
network_mode: host
restart: unless-stopped
volumes:
- ./config/pdns.conf:/etc/powerdns/pdns.conf:ro
- pdns-data:/var/lib/powerdns # SQLite db lives here
environment:
- PDNS_AUTH_API_KEY=changeme
volumes:
pdns-data:
`conf
pdns.conf — key paths that differ from Debian package defaults
launch=gsqlite3
gsqlite3-database=/var/lib/powerdns/pdns.sqlite3
socket-dir=/var/run/pdns # must match what's inside the container image
local-address=0.0.0.0
local-port=53
api=yes
api-key=changeme
webserver=yes
webserver-address=0.0.0.0
webserver-port=8081
`
Initialize the schema before first start — the container won't create it automatically:
`shell
Run once against the named volume to create the schema
docker run --rm \
-v pdns-data:/var/lib/powerdns \
powerdns/pdns-auth-48:4.8.3 \
pdnsutil create-bind-db /var/lib/powerdns/pdns.sqlite3
`
After that, docker compose up -d should bring PowerDNS up cleanly. Test with dig @127.0.0.1 your.domain A from the host — if you get a REFUSED instead of NXDOMAIN, your allow-query or local-address setting isn't matching the interface the query arrived on, which loops back to the network mode decision above.
What to Point Your Clients At and How to Test It
The most common mistake after getting Bind9 or PowerDNS responding correctly on the server is assuming all your clients will just pick it up. They won't — not until you tell your router to advertise the new DNS server via DHCP. Go into your router's DHCP server config (usually under LAN settings) and replace the default DNS server field with the static IP of your DNS host. Every client that renews its lease after that change will start using your resolver automatically. No touching individual machines, no static DNS entries on laptops you'll forget about. The one gotcha: clients mid-lease won't switch until renewal, so either wait or run sudo dhclient -r && sudo dhclient on Linux, or ipconfig /release && ipconfig /renew on Windows to force it.
Once a client has renewed, the first real test is dead simple:
`shell
From any DHCP client on your LAN — not the DNS host itself
nslookup myservice.home.lab
Expected output:
Server: 192.168.1.53
Address: 192.168.1.53#53
Name: myservice.home.lab
Address: 192.168.1.100
`
If you see the right IP and the server field shows your DNS host rather than 8.8.8.8, the DHCP push worked. If it still shows 8.8.8.8, the lease hasn't renewed — force it and try again.
Split-horizon verification deserves its own explicit check because misconfigured forwarders are subtle. The goal is three separate behaviors working simultaneously: external names forward out correctly, internal names resolve locally, and your internal names don't leak onto public DNS. Test all three explicitly, not just the one that seems most obvious:
`shell
External name via your resolver — should return real GitHub IPs
dig github.com @192.168.1.53
Internal name via your resolver — should return your LAN A record
dig vault.home.lab @192.168.1.53
Internal name against Google — should return NXDOMAIN, NOT your LAN IP
If this returns an IP, you have a leak or a naming collision
dig vault.home.lab @8.8.8.8
`
The third query is the one people skip, and it's the important one. If vault.home.lab returns anything from 8.8.8.8 other than NXDOMAIN, either someone registered that domain publicly (use a proper RFC 2606 suffix like .internal or .home.arpa to avoid this entirely) or your split-horizon config is wrong.
For ongoing health monitoring, a cron job beats installing another agent. The dig exit code is reliable — it returns non-zero on timeout or SERVFAIL, which covers the most common failure modes: the daemon crashed, the socket isn't listening, or the zone got corrupted. Drop this in your DNS host's crontab:
`shell
/etc/cron.d/dns-health — runs every 5 minutes, mails root on failure
*/5 * * * * root dig +time=2 +tries=1 @127.0.0.1 myservice.home.lab A > /dev/null || \
echo "DNS health check failed at $(date)" | mail -s "DNS DOWN" root
`
If you're already running Uptime Kuma — and if you're doing any home lab monitoring it's worth having — add a DNS monitor pointing at your resolver's IP with the hostname vault.home.lab and expected response set to your LAN IP. Kuma will poll it on whatever interval you set and give you a dashboard entry alongside your HTTP checks. That's better than the cron job for visibility, but the cron job runs even if Kuma itself is down, so keep both.
Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.
Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.
Top comments (0)