Bind9 vs PowerDNS on a Home Server: A Decision You Can Actually Make

TL;DR: Which One Should You Run

## TL;DR: Which One Should You Run

**Bind9** if you want static zone files, minimal moving parts, and you’re comfortable editing text and reloading a daemon. **PowerDNS** if you need an HTTP API, want zones stored in a database, or are building any kind of automation around DNS record management. That’s the whole decision. Everything below is justification and nuance.

| Factor | Bind9 | PowerDNS (Auth + Recursor) |
|—|—|—|
| Setup time | 20–40 min for a working internal zone | 45–90 min including database init and API config |
| Automation API | None native (use `rndc` + file edits) | Built-in REST API on port 8081 |
| Zone file format | Standard RFC 1035 text files | Database rows (MySQL/PostgreSQL/SQLite) or BIND-format via pipe backend |
| RAM at idle | ~15–30 MB (`named` process) | ~50–80 MB combined (`pdns` auth + `pdns-recursor`) |
| DHCP integration complexity | Manual or scripted zone file updates + `rndc reload` | API call per lease event; Kea has a built-in PowerDNS hook |

### The setup that actually wins for a lot of home operators

Neither option alone is the clean answer for recursive + authoritative separation. The architecture many people land on is **Bind9 as the authoritative server for internal zones** paired with **Unbound as the recursive resolver**. Unbound handles all your upstream queries, DNSSEC validation, and caching. Bind9 answers authoritatively for your local zone. You point Unbound at Bind9 via a `forward-zone` stub, and nothing about that config is particularly fragile.

That pairing deserves a mention before you pick one of the two headliners, because if you’re starting fresh and recursion + internal zones are both requirements, the Bind9 + Unbound split is worth considering before you commit to the PowerDNS stack.

### One naming decision to get right before you write a single config line

Every code block in this article uses `home.arpa.` as the example zone, full stop. RFC 8375 designates `home.arpa.` specifically for residential network namespaces. It won’t leak into the public DNS, resolvers that follow the RFC will treat it correctly, and you won’t fight namespace collisions the way you will with made-up TLDs like `.local` (claimed by mDNS/Bonjour) or `.internal` (not officially delegated and historically problematic). If your current setup uses something else, that’s a reasonable migration to make and the config changes are mechanical.

Why Router DNS Breaks and Why /etc/hosts Sprawl Is the Wrong Fix

## Why Router DNS Breaks and Why /etc/hosts Sprawl Is the Wrong Fix

Your router’s DNS works until it doesn’t. Three specific failure states push people toward running their own resolver:

– **Hostnames don’t resolve across VLANs.** Your router hands out DNS via DHCP on each VLAN, but inter-VLAN queries for local hostnames return NXDOMAIN. The resolver doesn’t know about the other segment. You end up pinging IPs.
– **Docker containers ignore host resolution entirely.** By default, containers query 8.8.8.8. Unless you’ve set `”dns”` in `/etc/docker/daemon.json`, your internal hostnames silently fail inside Compose stacks. No error, no warning — just failed connections that look like app bugs.
– **mDNS collapses the moment you add a second subnet.** Multicast doesn’t cross router boundaries. `hostname.local` works on the couch and nowhere else.

The instinct is to patch this with `/etc/hosts` entries scattered across machines. That scales to about four hosts before it becomes a maintenance liability.

Pi-hole is the usual next stop, and it’s a legitimate option for ad blocking with DNS. Note: Pi-hole v6 (released early 2025) replaced the older FTL/dnsmasq fork with a built-in resolver and a redesigned architecture — describing it as “a web UI on top of dnsmasq” is no longer accurate. It handles the blocking use case well. It’s not designed to be an authoritative nameserver for your internal zone or to answer split-horizon queries cleanly.

That gap is what Bind9 and PowerDNS are actually solving here.

Bind9 Setup Walkthrough: Authoritative Internal Zones on Ubuntu 24.04

## Bind9 Setup Walkthrough: Authoritative Internal Zones on Ubuntu 24.04

Ubuntu 24.04 (Noble Numbat) ships Bind9 9.18.x in its default repos. That’s the current stable branch from ISC, and it’s what you’ll get with a straight `apt install bind9` on a fresh Noble install. No PPA needed. The 9.18 series is a long-term supported branch, which matters for a home server you don’t want to babysit constantly.

bash
apt install bind9 bind9utils dnsutils

`bind9utils` gets you `named-checkconf` and `named-checkzone`. Install it. You’ll need both before the end of this section.

### Zone Declaration in named.conf.local

The file `/etc/bind/named.conf.local` is where you declare your zones. Don’t touch `named.conf.options` yet — that comes later. Here’s a complete declaration for a `home.arpa` zone:

// /etc/bind/named.conf.local

zone “home.arpa” {
type master;
file “/etc/bind/zones/db.home.arpa”;
allow-update { none; };
allow-query { 192.168.0.0/16; };
notify no;
};

`allow-update { none; }` is explicit about rejecting dynamic DNS updates. If you add DHCP integration later, you’ll revisit this. For now, static is fine and safer.

Create the zones directory:

bash
mkdir -p /etc/bind/zones
chown root:bind /etc/bind/zones
chmod 775 /etc/bind/zones

### The Zone File

This is the part most tutorials skip half of. Here’s a zone file that actually works, including the wildcard A record:

; /etc/bind/zones/db.home.arpa

$ORIGIN home.arpa.
$TTL 3600

@ IN SOA ns1.home.arpa. hostmaster.home.arpa. (
2024041501 ; Serial (YYYYMMDDnn format)
3600 ; Refresh
900 ; Retry
604800 ; Expire
300 ; Negative cache TTL
)

; Name servers
@ IN NS ns1.home.arpa.

; NS glue record
ns1 IN A 192.168.1.1

; Specific hosts
gateway IN A 192.168.1.1
nas IN A 192.168.1.10
proxmox IN A 192.168.1.20
pihole IN A 192.168.1.53

; Wildcard — catches anything not explicitly listed above
; Points to a local reverse proxy or catch-all service
* IN A 192.168.1.50

A few things that trip people up here:

**The serial number matters.** If you edit the zone file and forget to increment the serial, Bind9 will reload the file but won’t propagate changes to any secondary servers. Use `YYYYMMDDnn` format. The `nn` suffix lets you do up to 99 changes per day, which is more than enough.

**The wildcard only catches names not explicitly defined above it.** `nas.home.arpa` will resolve to `192.168.1.10`, not `192.168.1.50`. That’s correct DNS wildcard behavior per [RFC 4592](https://datatracker.ietf.org/doc/html/rfc4592). Don’t expect the wildcard to override specific records.

**The `$ORIGIN` line matters for relative names.** Every bare hostname in the file (like `nas`) expands to `nas.home.arpa.` because of `$ORIGIN`. If you accidentally add a trailing dot to a relative name, you’ll break the expansion. `nas.` in the file means literally `nas.` — not `nas.home.arpa.`

### Split-Horizon with Named Views

This is where Bind9 shows its institutional age in both good and bad ways. The configuration is verbose. It’s also completely explicit, which means you can trace exactly what will happen for any source IP.

The goal: internal clients (anything in `192.168.0.0/16`) get real RFC 1918 answers. Anything outside that range hits the `external` view and gets `NXDOMAIN` for your internal zones. No internal topology leaks to the internet.

**The critical constraint:** once you use views, *every zone* must live inside a view. You can’t mix view-scoped and global zone declarations. This catches people off guard when they add a view block to an existing config that has zones declared outside it.

Here’s `named.conf.options`:

// /etc/bind/named.conf.options

options {
directory “/var/cache/bind”;

// Listen only on internal interface
listen-on { 127.0.0.1; 192.168.1.1; };
listen-on-v6 { ::1; };

// Don’t accept recursive queries from the internet
allow-recursion { 192.168.0.0/16; 127.0.0.1; };

// Upstream resolvers for recursive queries
forwarders {
9.9.9.9;
149.112.112.112;
};
forward only;

// Harden
dnssec-validation auto;
auth-nxdomain no;
version none;
hostname none;
};

Now `named.conf.local` with views:

// /etc/bind/named.conf.local

// ACLs — define before views
acl “internal_nets” {
192.168.0.0/16;
127.0.0.1;
};

view “internal” {
match-clients { internal_nets; };
recursion yes;

// Internal zone — real answers
zone “home.arpa” {
type master;
file “/etc/bind/zones/db.home.arpa”;
allow-update { none; };
};

// Include default zones inside the view
include “/etc/bind/named.conf.default-zones”;
};

view “external” {
match-clients { any; };
recursion no;

// Same zone name, but returns NXDOMAIN for everything
// Achieved by using an empty zone or a zone with only SOA+NS
zone “home.arpa” {
type master;
file “/etc/bind/zones/db.home.arpa.external”;
allow-update { none; };
};
};

The external zone file at `/etc/bind/zones/db.home.arpa.external`:

; External view — authoritative but empty
; Clients outside internal_nets get NXDOMAIN for all home.arpa names

$ORIGIN home.arpa.
$TTL 300

@ IN SOA ns1.home.arpa. hostmaster.home.arpa. (
2024041501
3600
900
604800
300
)

@ IN NS ns1.home.arpa.

; Deliberately no A records
; All queries for *.home.arpa return NXDOMAIN

This approach — an authoritative zone with no records — returns `NXDOMAIN` (technically `NOERROR` with empty answer section for the SOA query itself, but `NXDOMAIN` for any specific name). If you want a strict `REFUSED` instead, set `allow-query { none; }` on the external zone. Pick one behavior and be consistent.

**Known pain point:** if you add a new zone later and forget to put it in both views, the view that doesn’t have it will behave unpredictably — it may fall through to the next view or return `SERVFAIL` depending on how Bind9 handles the miss. Test after every zone addition.

### Validation Before You Reload

Don’t reload without checking. Both of these commands exit non-zero on error, which means you can put them in scripts or pre-commit hooks.

**Check the entire named configuration:**

bash
named-checkconf /etc/bind/named.conf

Clean output looks like this — no output at all:

(no output, exit code 0)

If you get output, it’s an error. A common one:

/etc/bind/named.conf.local:12: unknown option ‘mach-clients’

That’s a typo in `match-clients`. Bind9’s error messages are usually good enough to find the line number.

**Check the zone file syntax:**

bash
named-checkzone home.arpa /etc/bind/zones/db.home.arpa

Expected output on success:

zone home.arpa/IN: loaded serial 2024041501
OK

If your wildcard record is malformed, you’ll see something like:

dns_rdata_fromtext: /etc/bind/zones/db.home.arpa:22: near ‘*’: bad name (check-names)

Run checkzone on both zone files — the internal and the external stub.

**After validation, reload:**

bash
systemctl reload bind9
# or equivalently:
rndc reload

`rndc reload` is preferable in production because it doesn’t restart the process — it just reloads zone data. On a home server the distinction barely matters, but it’s a good habit.

**Test with dig:**

bash
# Should return 192.168.1.50 (wildcard)
dig @192.168.1.1 anything.home.arpa A

# Should return 192.168.1.10 (explicit record)
dig @192.168.1.1 nas.home.arpa A

# Should return NXDOMAIN or empty answer (external view test)
# Simulate from an external IP using: rndc … or test from outside your subnet
dig @192.168.1.1 -b 10.0.0.1 secret.home.arpa A

The `-b` flag on `dig` sets the source address. Most home routers won’t route a spoofed source address, so you may need an actual device outside your subnet to test the external view properly. A VPS with a public IP pointed at your WAN works.

### Reference Documentation

The ISC publishes the canonical Bind9 9.18 ARM (Administrator Reference Manual) at [https://bind9.readthedocs.io/en/v9.18/](https://bind9.readthedocs.io/en/v9.18/). The views documentation is in the “Configuration” chapter under “View Statement”. It’s dense but accurate — when the config isn’t behaving, the ARM is usually where you find out which option you misread.

The `named-checkconf` man page is available locally after install:

bash
man named-checkconf

Or online at [https://bind9.readthedocs.io/en/v9.18/manpages.html](https://bind9.readthedocs.io/en/v9.18/manpages.html). Check the `named-checkzone` man page while you’re there — the `-i` flag for integrity checking on NS records is worth knowing about.

PowerDNS Setup Walkthrough: API-Driven Zones with SQLite Backend

## PowerDNS Setup Walkthrough: API-Driven Zones with SQLite Backend

Version note before anything else: if you’re following a tutorial that references PowerDNS Authoritative 4.8.x, that’s stale. Current stable as of 2026 is **4.9.x**. The recursor has its own release track — `pdns_recursor` is at **5.x**. These are separate packages with separate configs; conflating them is a common early mistake. Check the [PowerDNS changelog](https://doc.powerdns.com/authoritative/changelog/) before installing from your distro’s default repo, because some package mirrors lag behind by a minor version.

### Installing and Picking a Backend

On Debian/Ubuntu:

bash
apt install pdns-server pdns-backend-sqlite3

That installs the authoritative server plus the SQLite backend. Do not install `pdns-recursor` at the same time without understanding that you now have two separate daemons, on separate ports, with separate configs. Many broken setups come from running both on port 53 and wondering why one silently dies.

### Initialize the SQLite Database

PowerDNS ships a schema file. Use it:

bash
sqlite3 /var/lib/powerdns/pdns.sqlite3 \
< /usr/share/doc/pdns-backend-sqlite3/schema.sqlite3.sql Verify the schema landed: bash sqlite3 /var/lib/powerdns/pdns.sqlite3 ".tables" # Expected output: comments cryptokeys domainmetadata domains records supermasters tsigkeys If you see fewer tables, the schema didn't fully apply. Start over with a fresh file rather than trying to patch it. --- ### Minimum `pdns.conf` Location is `/etc/powerdns/pdns.conf`. Strip it to what actually matters: ini # Backend launch=gsqlite3 gsqlite3-database=/var/lib/powerdns/pdns.sqlite3 # Listen only on loopback and your LAN interface — not 0.0.0.0 local-address=127.0.0.1,192.168.1.10 local-port=53 # API — this is the whole reason you're here api=yes api-key=changeme-use-a-real-secret webserver=yes webserver-address=127.0.0.1 webserver-port=8081 webserver-allow-from=127.0.0.1 # Don't forward unknown queries — you're authoritative only # Pair with a separate recursor if you need recursion `local-address` being explicit matters. Binding to `0.0.0.0` on a home server means anyone on your network can send queries to your authoritative server, which will return SERVFAIL for anything it doesn't know. Annoying to debug. Restart and check it's up: bash systemctl restart pdns pdns_control ping # PONG --- ### Using the REST API to Create a Zone and Add a Wildcard Record This is where PowerDNS earns its keep over flat zone files. No syntax errors, no `rndc reload`, no touching files at all. **Create the `home.arpa.` zone:** bash curl -s -X POST http://127.0.0.1:8081/api/v1/servers/localhost/zones \ -H "X-API-Key: changeme-use-a-real-secret" \ -H "Content-Type: application/json" \ -d '{ "name": "home.arpa.", "kind": "Native", "nameservers": ["ns1.home.arpa."] }' Response will include the zone object with an `id` field. If you get a 422, the zone name is malformed — trailing dot is required. **Add a wildcard A record** so `*.home.arpa.` resolves to your LAN gateway or a catch-all host: bash curl -s -X PATCH http://127.0.0.1:8081/api/v1/servers/localhost/zones/home.arpa. \ -H "X-API-Key: changeme-use-a-real-secret" \ -H "Content-Type: application/json" \ -d '{ "rrsets": [ { "name": "*.home.arpa.", "type": "A", "ttl": 300, "changetype": "REPLACE", "records": [ { "content": "192.168.1.1", "disabled": false } ] } ] }' A `200 OK` with an empty body means it worked. Verify: bash dig @127.0.0.1 anything.home.arpa A # Should return 192.168.1.1 The `REPLACE` changetype is idempotent — run it again and you won't get duplicate records. This makes it safe to call from scripts and automation without checking first whether a record exists. That's a meaningful operational difference from editing zone files by hand. --- ### The SQLite Locking Problem: What Actually Happens SQLite uses a file-level write lock. Under normal home server conditions — one or two things querying the API — you will never notice this. But there are specific failure modes worth knowing before they bite you. **The scenario:** something is hammering the API with record updates (a DHCP hook, a script pushing DNS registrations for new containers, a webhook handler) at the same moment PowerDNS is serving queries that require a backend read. SQLite's write lock blocks the read. PowerDNS can't complete the query. It returns SERVFAIL. A single SERVFAIL is invisible. A cascade isn't. If your resolver retries, and the lock is still held, every query in flight during that window fails. From the client side this looks like "DNS is down." **How to detect it:** `pdns_control rping` hits the backend and returns the round-trip latency. Under a lock contention event, this will hang or return a timeout error rather than a clean latency number. Pair that with watching `/var/log/syslog` for `GSQLite3 backend unable to launch in query mode` — that log line is the actual signal that backend reads are failing. You can also watch the backend directly: bash watch -n 1 'sqlite3 /var/lib/powerdns/pdns.sqlite3 ".timeout 500" "SELECT count(*) FROM records;"' If that command hangs, something else has the lock. **When to migrate:** SQLite is fine for read-heavy, write-rarely setups. If you're auto-registering DNS records for ephemeral containers, running Ansible plays that update records in batch, or running anything that writes more than a handful of records per minute, switch to PostgreSQL. The PowerDNS gmysql and gpgsql backends handle concurrent writes without locking the entire database. The [official authoritative server documentation](https://doc.powerdns.com/authoritative/) covers the backend configuration for both — the schema files ship with the respective backend packages, same as SQLite. The migration path is not painful: dump your zones via the API (`GET /api/v1/servers/localhost/zones`), stand up a new backend, import the zone data via the same API. No proprietary export format to fight with. That's a genuine advantage of the API-first design.

Operational Realities: Failure Modes, Resource Use, and Restart Behavior

## Operational Realities: Failure Modes, Resource Use, and Restart Behavior

This section is where most comparisons fall apart. They benchmark query throughput on a dedicated server and call it done. You’re running on a Pi or a repurposed NUC. The numbers that matter are idle RAM, recovery time after a bad config push, and how long it takes you to figure out why clients are ignoring your changes.

### RAM Footprint: Actual Numbers, Not Vibes

Bind9 `named` at idle on a Raspberry Pi 4 runs somewhere in the **15–30 MB RSS** range depending on zone count and whether you’re doing recursion. A few dozen local zones, authoritative-only, expect the low end of that. Enable recursion with a decent cache and it climbs.

PowerDNS Authoritative alone sits lighter than you’d expect — roughly **25–35 MB** idle. The problem is you almost never run it alone. Add `pdns_recursor` for local clients and you’re looking at **50–80 MB combined** before anything interesting happens. That’s not a disaster on a Pi 4 with 4 GB, but on a Pi Zero 2 W or an older 1 GB board running Pi-hole alongside it, you’re eating into real headroom.

The architectural reason matters: PowerDNS splits the authoritative and recursive functions into separate processes by design. That separation gives you cleaner semantics and independent restart behavior, but it costs you the second memory footprint. Bind9 does both in one process with one config file. Whether that’s a feature depends on your situation.

If you’re RAM-constrained, Bind9 wins this comparison. Not because it’s architecturally superior — just because it’s one process.

### Zone Reload Behavior

This is where the two products have genuinely different operational personalities.

**Bind9** treats zone files as the source of truth. You edit the file, you increment the serial, you run `rndc reload` (or `rndc reload zonename.example` for surgical reloads). Nothing happens automatically. The SOA serial format most people use is `YYYYMMDDNN` — year, month, day, two-digit daily revision counter:

@ IN SOA ns1.home.arpa. admin.home.arpa. (
2024061401 ; Serial: June 14 2024, first change of the day
3600 ; Refresh
900 ; Retry
604800 ; Expire
300 ) ; Negative TTL

Forget to increment that serial and Bind9 will silently refuse to reload the zone. The zone you think you deployed is not the zone running. This is a common failure mode. Set a reminder, use a script that auto-increments, or accept that you’ll debug it at least once.

**PowerDNS Authoritative** with the API enabled is a different animal entirely. A `PATCH` or `POST` to the REST API is live immediately. No reload command, no serial gymnastics, no waiting. The serial increments automatically when the API touches a record. This matters a lot if you’re driving DNS from Ansible playbooks, n8n automation workflows, or any CI/CD pipeline that adds service records on deploy. The round-trip in Bind9 — write file, increment serial, call `rndc reload` — is friction that compounds when you’re updating dozens of records across a deploy.

For **static zones that rarely change**, Bind9’s explicit reload cycle is actually fine and arguably safer. You don’t accidentally deploy a bad record because you fat-fingered an API call. Everything requires deliberate human action.

For **programmatic updates**, PowerDNS’s API model is the correct choice. Bind9’s zone file workflow was designed for human-operated infrastructure. It shows.

### Troubleshooting Matrix: Five Failure States

#### 1. Zone not loading — Bind9 silent failure

**Symptom:** `rndc reload` returns OK but the old record is still serving.

**Cause:** SOA serial not incremented. Bind9 compares the serial before and after. If it hasn’t changed, it assumes the zone hasn’t changed and skips the reload.

**Fix:** Increment the `YYYYMMDDNN` serial in the SOA record before reloading. Check with:

bash
named-checkzone home.arpa /etc/bind/zones/db.home.arpa

This will catch serial issues, syntax errors, and missing trailing dots before you reload.

#### 2. Clients ignoring new DNS — DHCP lease not expired

**Symptom:** You updated a record, it resolves correctly from the server, but client machines still hit the old IP.

**Cause:** The client cached its DNS server assignment via DHCP. DHCP option 6 carries the DNS server address. Until the lease renews, the client may still be pointing at an old resolver or using a stale address.

**Fix on OpenWrt with dnsmasq:** Push your local resolver explicitly via `server=` directive in `/etc/dnsmasq.conf`:

server=/home.arpa/192.168.1.10
dhcp-option=6,192.168.1.10

`dhcp-option=6` is the DHCP option 6 override — it explicitly tells DHCP clients which DNS server to use. After changing this, force a lease renewal on clients (`ipconfig /renew` on Windows, `dhclient -r` on Linux, disconnect/reconnect on mobile). Don’t wait for natural expiry if you need resolution now.

#### 3. Docker containers still hitting 8.8.8.8

**Symptom:** Everything on your LAN resolves correctly. Docker containers resolve external names but can’t reach internal hostnames.

**Cause:** Docker’s default DNS is hardcoded to `8.8.8.8` unless overridden in `daemon.json`. The container network doesn’t inherit your host’s `/etc/resolv.conf` in the way you’d expect.

**Fix:** Edit `/etc/docker/daemon.json` and add:

json
{
“dns”: [“192.168.1.10”],
“dns-search”: [“home.arpa”]
}

Restart the Docker daemon (`systemctl restart docker`). Existing containers need to be recreated, not just restarted, to pick up the new DNS configuration. `docker restart` is not enough — it doesn’t reinitialize the network namespace.

Alternatively, set `dns:` per `docker-compose.yml` for individual stacks if you don’t want to change the global daemon config.

#### 4. Split-brain: mDNS overriding authoritative answers on macOS

**Symptom:** Your authoritative DNS returns the correct IP for `device.home.arpa`. The Mac ignores it and resolves something different, or fails with NXDOMAIN.

**Cause:** macOS’s `mdnsresponder` handles `.local` domains natively via mDNS (Bonjour) and intercepts certain resolution requests before they reach your DNS server. More broadly, macOS uses a resolver ordering system that doesn’t always send queries to your configured DNS first.

**Workaround:** Use `.home.arpa` instead of `.local` as your internal TLD. Apple officially cedes `.home.arpa` to local DNS and `mdnsresponder` will not intercept it. If you’re committed to a custom TLD like `.lan` or `.internal`, create a resolver config file on macOS:

# /etc/resolver/home.arpa
nameserver 192.168.1.10

This tells the macOS resolver to send `*.home.arpa` queries to your nameserver instead of letting `mdnsresponder` handle them. Check it worked with `scutil –dns` — your domain should appear in the resolver list.

The long-term answer is: stop using `.local` for anything except actual mDNS devices. Use `.home.arpa`. This problem mostly disappears.

#### 5. PowerDNS returning REFUSED

**Symptom:** Clients get `REFUSED` responses instead of answers. `dig` confirms it’s your PowerDNS server responding with REFUSED.

**Cause:** Two distinct issues share this symptom.

First, **`allow-recursion`**: if clients are sending recursive queries to the Authoritative server instead of the recursor, and your authoritative config doesn’t allow recursion (correct behavior), it REFUSEs. Check whether you’re pointing clients at the right port/process. PowerDNS Authoritative defaults to port 53; pdns_recursor also wants port 53. On a single host, one of them needs to run on an alternate port or bind to a specific interface.

Second, **`local-address` binding**: if PowerDNS is bound only to `127.0.0.1`, external client queries hit a closed port or get refused at the network layer. Check `pdns.conf`:

local-address=0.0.0.0
local-port=53

Or bind to your specific LAN interface IP. Restart after changes and verify with `ss -ulnp | grep 53`.

### When NOT to Use Each One

**Don’t run Bind9** if you need programmatic zone updates from any automation layer — CI/CD, Ansible, n8n, or a custom script running on deploy. The file-edit → serial-increment → `rndc reload` cycle is not designed for that workflow. You’ll either write a fragile wrapper script or you’ll occasionally deploy stale DNS because you forgot to increment the serial. PowerDNS’s API exists precisely because this is a real problem.

**Don’t run PowerDNS Authoritative alone** if your clients need recursive resolution with DNSSEC validation. The Authoritative server resolves nothing for clients — it only answers for zones it owns. You need `pdns_recursor` or Unbound running alongside it to handle general resolution. That’s an additional process, additional config, additional memory, and additional failure surface. For a simple home setup where you want one thing to configure and maintain, Bind9 with both authoritative and recursive modes enabled in a single process is genuinely simpler.

Neither of these products is wrong. They’re optimized for different operational patterns. The choice is about which failure modes you’d rather debug at 11pm.

Running Both: Bind9 Authoritative + Unbound Recursive

## Running Both: Bind9 Authoritative + Unbound Recursive

This is the architecture most home lab operators eventually land on after running either tool solo for a while. Bind9 owns the authoritative side — it answers questions about your internal zones like `home.arpa.` or whatever private domain you’ve carved out. Unbound owns the recursive side — it actually talks to the internet, validates DNSSEC, and caches results for your LAN clients. Neither tool does the other’s job, and that clean split is exactly the point.

Your clients talk to Unbound on port 53. Unbound handles everything except queries that match your internal zones, which it forwards to Bind9 on `127.0.0.1:5300`. Bind9 never talks to the internet at all in this setup. It just answers for the zones it knows about.

### Unbound configuration blocks that matter

The forward zone block that routes internal queries to Bind9:

forward-zone:
name: “home.arpa.”
forward-addr: 127.0.0.1@5300

If you’re using a custom internal TLD (say, `.internal` or `.lan`), duplicate that block for each zone. Unbound will match the longest suffix, so a query for `printer.home.arpa.` hits this block, and everything else falls through to your upstream resolvers or root hints.

The access control line that lets LAN clients actually query Unbound:

access-control: 192.168.1.0/24 allow

Add one line per subnet if you’re running VLANs. The default Unbound config on most distros only allows localhost, so LAN clients will get refused without this. It’s a common first-hour failure when setting this up.

Bind9’s listener configuration needs to match what Unbound expects:

options {
listen-on port 5300 { 127.0.0.1; };
allow-query { 127.0.0.1; };
};

Bind9 doesn’t need to be reachable from the LAN at all. Locking it to localhost on a non-standard port removes an entire class of misconfiguration risk.

### Why Unbound instead of pdns_recursor here

If you’re searching for *unbound vs powerdns recursor home lab*, this is the comparison that actually matters for this pattern.

`pdns_recursor` is a capable piece of software. It handles this role fine. But in practice, operators pick Unbound for a few specific reasons:

**Single binary, no external dependencies.** `pdns_recursor` works independently from the PowerDNS Authoritative Server, but if you’re already running Bind9 on the authoritative side, adding pdns_recursor means you now have two separate PowerDNS packages that are not the same thing, versioned separately, with separate config syntax. Unbound is one package, one config file, one service.

**DNSSEC validation is native and the defaults are reasonable.** Unbound ships with root trust anchor management built in. On Debian/Ubuntu, `unbound-anchor` handles the root KSK. You’re validating by default without extra configuration. With pdns_recursor you can absolutely get DNSSEC validation working, but it requires more deliberate setup and the failure modes are less obvious when something breaks.

**The mental model stays clean.** Bind9 is authoritative. Unbound is recursive. They don’t share config formats, don’t share ports, and don’t interfere with each other. When something breaks, you know immediately which half to look at.

The honest caveat: if you’re already deep in the PowerDNS ecosystem — running pdns_auth for your authoritative zones, using the API, maybe feeding zone data from a database — then pdns_recursor is the natural fit because you’re already managing that complexity. Unbound wins on simplicity for operators who want the recursive piece to be as boring as possible.

### Where this lands in the article’s overall comparison

This setup is the practical middle path between “just run Bind9 for everything” and “just run PowerDNS for everything.” Bind9 authoritative plus Unbound recursive is what you reach for when you want clear functional boundaries, minimal operational overhead, and DNSSEC that actually works without debugging trust anchor configuration at midnight. It doesn’t require you to pick a winner in the Bind9 vs PowerDNS debate — it just assigns each tool to the job it’s genuinely good at.

Connecting Your Network: DHCP, Docker, and Client Cache Flushing

## Connecting Your Network: DHCP, Docker, and Client Cache Flushing

Getting the DNS server running is half the job. The other half is making sure every device on your network actually uses it. Most guides stop at “point your router at your new DNS server.” That’s not enough.

### Pushing the Resolver to DHCP Clients

DHCP option 6 is the DNS server option. You need to set it explicitly, not just change a field in a web UI and hope it propagates.

**OpenWrt with dnsmasq** — add this to `/etc/dnsmasq.conf` or the UCI equivalent:

dhcp-option=6,192.168.1.X

Replace `192.168.1.X` with the IP of your Bind9 or PowerDNS host. If you want a fallback for when your resolver is down, pass two addresses:

dhcp-option=6,192.168.1.X,1.1.1.1

The fallback matters. If your local resolver goes down and you only pushed one DNS address, every device on your network loses name resolution completely. Whether you want that fallback to exist or not is a deliberate choice — don’t let it be an accident.

**ISC DHCP server** — in `/etc/dhcp/dhcpd.conf`, inside your subnet block:

subnet 192.168.1.0 netmask 255.255.255.0 {
option domain-name-servers 192.168.1.X;
option domain-name “home.lab”;
range 192.168.1.100 192.168.1.200;
}

The `option domain-name` line is worth including. It lets short hostnames resolve without a fully qualified domain name, so `nas` resolves instead of requiring `nas.home.lab` everywhere.

After changing either config, existing DHCP leases won’t pick up the new DNS until they renew. Force it by releasing and renewing on each client, or shorten your lease time temporarily, or just wait. On most home networks, leases are 24 hours by default.

### Docker DNS Integration

Containers do not automatically use your host’s resolver. They default to whatever Docker decides at daemon startup, which is usually `8.8.8.8` if systemd-resolved isn’t configured to hand off correctly.

**Per-service override in `docker-compose.yml`:**

yaml
services:
myapp:
image: myapp:latest
dns:
– 192.168.1.X

This overrides DNS only for that service. Every other service in the same compose file keeps using the default. Useful when you have a container that specifically needs to resolve internal names — say, a service that calls a local API endpoint by its internal hostname.

**Global override in `/etc/docker/daemon.json`:**

json
{
“dns”: [“192.168.1.X”, “1.1.1.1”]
}

This applies to every container on the host that doesn’t have a per-service `dns` key. Restart Docker after changing it: `sudo systemctl restart docker`. Be careful here — if your local resolver is unreachable when a container starts, and you haven’t put a public fallback in that array, the container’s DNS will fail silently. The compose-level `dns` key overrides `daemon.json`, so per-service settings win.

The two mechanisms compose rather than conflict: `daemon.json` is your baseline, compose `dns` is your escape hatch. Don’t rely on just one.

### Client Cache Flushing After a DNS Change

When you push a new record or change your resolver, clients hold stale answers until their local cache expires. Force it manually:

– **Linux (systemd-resolved):** `sudo resolvectl flush-caches`
– **macOS:** `sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder`
– **Windows:** `ipconfig /flushdns`

### Automating Zone Records via the PowerDNS REST API

If you chose PowerDNS, there’s an angle worth considering before you start manually adding A records every time you spin up a new container. PowerDNS exposes a REST API out of the box. Every record create, update, or delete is an HTTP call. That pairs naturally with an n8n HTTP Request node — you can build a workflow that watches for new Docker containers or new DHCP leases and automatically creates the corresponding DNS record without touching the PowerDNS console. The [n8n workflow automation setup covered here](/n8n-workflow-automation) walks through the HTTP Request node configuration in enough detail that wiring it to the PowerDNS `/api/v1/servers/localhost/zones/{zone}` endpoint is straightforward from there.

Bind9 does not have a built-in REST API. You’d need `nsupdate` over a script, which is doable but more friction. This is one of the concrete operational differences between the two — not a theoretical one.

FAQ

## FAQ

### Can I run Bind9 and PowerDNS on the same machine?

Technically yes. Bind on 53, PowerDNS on 5300 (or the other way around), both running concurrently. It works. Whether you *should* is a different question.

The operational reality is that you now have two zone files to keep in sync, two daemons to restart after config changes, and two different logging formats to parse when something breaks at 11pm. The cleaner architecture is one authoritative server — whichever you pick — fronted by Unbound handling recursion. Unbound forwards internal queries to your authoritative instance and resolves everything else upstream. One job each, clear separation.

Running two authoritative servers on the same box is usually a sign that a migration stalled halfway. Finish the migration.

### Does PowerDNS support DNSSEC for internal zones?

Yes, `pdns` can sign zones. The tooling is there — `pdnsutil secure-zone`, key rollovers, the whole thing.

For a home lab where your internal zone never leaves your network and has no external delegation, the complexity rarely pays off. DNSSEC on `home.arpa.` or `lab.internal` means managing signing keys, handling TTLs carefully so caches don’t serve stale signed records, and debugging validation failures that look identical to ordinary resolution failures until they don’t.

Where DNSSEC *does* matter on a home setup is at the recursor layer — validating responses from upstream resolvers. Configure your Unbound instance to validate DNSSEC on outbound queries. That’s the threat model that actually applies here (spoofed upstream responses). Signing your internal zones is mostly ceremony.

### What happens to DNS resolution if the server goes down?

Clients fall back to whatever secondary resolver DHCP handed them. If you only configured one, everything breaks. Not “degrades gracefully” — breaks. No DNS means half your LAN thinks the internet is down.

The practical fix: run a lightweight secondary on a second device. A second Bind9 instance with zone transfers configured, or even a `dnsmasq` forwarder on a Raspberry Pi that knows your internal hostnames statically. The secondary doesn’t need to be authoritative for everything — it just needs to keep answering while you restart the primary.

Most home lab failures happen during updates or config experiments on the primary. A secondary on separate hardware (ideally something that doesn’t get rebooted as often) absorbs that failure mode cleanly.

### Is `home.arpa.` actually required, or can I use `.lab` or `.internal`?

RFC 8375 explicitly reserves `home.arpa.` for residential network use. That’s the right answer for a home setup — it has a defined meaning, routers and resolvers can be configured to treat it specially, and it won’t collide with anything.

`.lab` is not IANA-reserved. It’s popular in home labs precisely because it sounds right, but there’s no guarantee a future TLD allocation won’t claim it. That’s a low-probability risk, but it’s real.

`.internal` is now formally reserved per RFC 9476, so collision risk there is resolved — but it’s reserved for internal use generically, not specifically for home networks. `home.arpa.` is the semantically correct choice if you want to follow the spec.

In practice, thousands of home labs run `.lab` without incident. Just don’t complain if something breaks in five years and it turns out `.lab` got delegated to a registry you’ve never heard of.


Eric Woo

Written by Eric Woo

Self-Hosted AI & Automation Engineer

Eric runs his own self-hosted stack: local LLM pipelines on Ollama with dual-model VRAM scheduling on a single 32GB workstation, n8n workflows in Docker, and a TypeScript automation engine that publishes to WordPress on cron. He writes about the systems he actually operates — configs, failure modes, and GPU bills included.

Leave a Comment