DNS is one of those services you only notice when it breaks. Mine is four resolver layers deep: dnsdist in front of a pool of Pi-holes, then BIND 9 for the internal zones, then Unbound talking DNS-over-TLS to the outside world. One Pi-hole would have covered the ad blocking, so a good part of this is overengineering on purpose, and the rest of this post is about which parts of it I would build again.
Everything here is deployed and re-deployed by a single Ansible role, and every number below was measured on the live VM while writing this (July 2026).
The chain#
| Component | Listens on | Job |
|---|---|---|
| dnsdist 1.9 | :53 | entry point: load balancing, health checks, packet cache |
| Pi-hole x7 (FTL v6) | :9991-:9997 | blocklists (4.79M domains), per-client policy, local cache |
| BIND 9.20 | 127.0.0.1:1053 | internal authoritative zones, rendered from Ansible inventory |
| Unbound 1.22 | 127.0.0.1:2054 | caching forwarder, authenticated DNS-over-TLS upstream |
Versions are the Debian 13 packages (dnsdist 1.9.15-0+deb13u1, BIND 1:9.20.26-1~deb13u1,
Unbound 1.22.0-2+deb13u3, Pi-hole 2026.07.2): Debian backports security fixes into these
versions instead of tracking upstream releases, which is why the numbers trail the current
upstream ones.
All of it lives in one Debian 13 VM with 10 vCPU and 8 GB RAM on one of the fanless Proxmox nodes. Cost at idle: about 1.7 GB RSS across the whole chain (Unbound’s caches are the biggest single item at ~730 MB) and a load average that rounds to zero.
To be precise about what the pool protects: this is redundancy within one VM. It shields the policy layer from a single Pi-hole restart, not from losing the VM or its Proxmox node.
Clients get one IP and port 53. That is the entire client-side configuration.
How a DNS query flows through the chain#
Each layer either answers the query or passes it down. As a decision tree:
flowchart TD Q["query arrives
dnsdist :53"] --> C1{"in dnsdist
packet cache?"} C1 -->|yes| A1["answer from cache"] C1 -->|no| P["Pi-hole
one of 7, :9991-:9997"] P --> C2{"on a blocklist?"} C2 -->|yes| A2["0.0.0.0 / ::
(blocking mode NULL)"] C2 -->|no| B["BIND 9 :1053"] B --> C3{"in an internal
authoritative zone?"} C3 -->|yes| A3["authoritative answer"] C3 -->|no| U["Unbound :2054"] U --> C4{"in Unbound
cache?"} C4 -->|yes| A4["answer from cache"] C4 -->|no| O["forward upstream
authenticated DoT :853"]
For readability, intermediate cache hits are omitted from the diagram: Pi-hole and BIND 9 cache too, and all four cache layers get their own section below.
And the full round trip for the worst case, a cold external name:
sequenceDiagram autonumber participant C as Client participant D as dnsdist participant P as Pi-hole participant B as BIND 9 participant U as Unbound participant O as Upstream Note over C,O: cold cache, external domain, nothing short-circuits C->>D: A query D->>P: forward (load balancing,
health checks run separately) P->>B: not on a blocklist B->>U: not in an internal zone U->>O: DoT :853 O-->>U: answer U-->>B: answer B-->>P: answer P-->>D: answer D-->>C: answer
Blocked queries get answered with the unspecified address (0.0.0.0 for A, :: for AAAA).
That is Pi-hole’s NULL blocking mode, its default and the recommended one: clients give up
faster than with NXDOMAIN and there is no fake block-page web server involved.
Why dnsdist, then Pi-hole, then BIND 9, then Unbound#
1) dnsdist: the front door#
dnsdist earns its place by doing three things well:
- Load balancing across the Pi-hole pool
- Health checks, so a dead backend stops receiving traffic
- Packet cache for the hottest queries, answered without touching anything below
Two honest details about the health checks. They are separate periodic probes, not part of
each client query. And the probe resolves an internal record, so on a cache miss it checks
dnsdist to Pi-hole to BIND 9; it deliberately does not test Unbound, and Pi-hole may answer
it from cache. The client-facing canary later in this post is the end-to-end test.
The sharp edge: if BIND 9 stalls, all seven backends can be marked down within one check
interval. The backstop is the roundrobin policy, which fails open and still forwards to a
backend when everything looks down instead of dropping queries on the floor.
How per-client stats survive a load balancer#
Every cache miss that reaches Pi-hole arrives from 127.0.0.1, so out of the box every
device in the house would show up in the dashboard as one client: the load balancer.
The fix is EDNS Client Subnet on the internal hop: dnsdist stamps the real client address into
each forwarded query (useClientSubnet plus setECSOverride(true) and a /32 source prefix),
and FTL prefers the ECS-carried address over the socket address when attributing queries.
Per-client rules keep working, and per-client statistics survive too, split across the seven
FTL instances: each dashboard sees only its share of the traffic.
Three things to know before copying this:
- The client address stays inside the box. Unbound sends no ECS upstream (it is off unless you
configure
send-client-subnet), so nothing leaks to the DoT providers. - A
/32ECS value becomes part of dnsdist’s cache key, so the packet cache fragments per client. dnsdist’s own docs warn a narrow ECS source “will effectively kill dnsdist’s cache ratio”. I keep it anyway: a dnsdist miss falls through to caches that answer in 0 ms, so the price of per-client visibility is close to nothing at household scale. - Queries answered from dnsdist’s packet cache never reach FTL, so they are invisible in Pi-hole’s per-client statistics. ECS preserves per-client policy and cache isolation; it cannot make dnsdist cache hits show up in the dashboard.
2) Pi-hole x7: redundancy for the policy layer#
Seven containers, ports :9991-:9997, identical config, all fed by dnsdist.
The number is not sacred: it dates from when the VM had fewer cores and I never revisited it.
What the pool actually buys:
- Restart isolation: gravity updates and container restarts are invisible behind the LB
- Rolling updates: I can update and validate one backend before touching the others
- Blocklist scale: 4,793,272 domains in gravity, and the seven FTL instances still fit comfortably inside the chain’s ~1.7 GB RSS
What it does not buy: end-to-end throughput. Every cache miss still funnels through one BIND 9 and one Unbound, so the pool parallelizes policy, not resolution. At household QPS none of that is a bottleneck anyway.
3) BIND 9: the internal universe#
BIND 9 is where my internal names live, and this is the part the Ansible role is proudest of: zones are rendered from the inventory itself. Every inventory group named like a domain becomes a zone, every host in it becomes an A record, plus a set of pinpoint zones for one-off names. Nothing is hand-edited; adding a VM to the inventory adds its DNS record on the next deploy.
Every generated zone file passes named-checkzone and the final config passes
named-checkconf -z as Ansible validate: steps, so a broken template can never reach a
running daemon.
If the name belongs to an internal zone, BIND 9 replies authoritatively. Everything else goes down the chain.
4) Unbound: authenticated DNS-over-TLS to the outside#
After the forward only fix described below, Unbound is the only resolver process allowed to
talk to the internet, and it is deliberately not recursing.
It is a caching forwarder pinned to four DoT endpoints across three providers
(Cloudflare, Quad9, Google) with forward-tls-upstream: yes, plus:
- TLS authentication that actually authenticates: each
forward-addrcarries a#hostnamesuffix andtls-cert-bundlepoints at the system CA store. Both parts matter: without the bundle the name check has nothing to verify against, and the man page is blunt that such connections “cannot be authenticated”. A failed handshake fails the query; there is no silent downgrade to plaintext port 53. - serve-expired (RFC 8767): when upstreams stop answering, expired cache entries are served for up to an hour instead of SERVFAIL. Not instantly, either: the config opts into the RFC’s client timer, so stale answers only appear after a resolution attempt has had its 1.8 seconds. My WAN can flap without the house noticing immediately.
- prefetch: popular records get refreshed before their TTL expires.
Who protects me from a lying ISP#
My threat model for DNS is not internal: I do not need DNSSEC between my laptop and my own resolver. The thing I actually want to prevent is the ISP, or anything else on the WAN path, seeing and rewriting my DNS answers. Two mechanisms cover it:
- Confidentiality and integrity in transit: authenticated DoT. The ISP can still see the resolver IPs, timing, and traffic volume, but not the queried names or the answers.
- Validation: all four upstreams are validating resolvers, so DNSSEC-bogus answers come back as SERVFAIL. I outsource validation to them and verify the behavior end to end.
The honest trade-off: this moves trust from the ISP to the resolver operators. Cloudflare, Quad9, and Google still see the queries they answer; spreading them across three providers softens that, it does not remove it.
The second point only holds if the chain preserves it, which brings me to the interesting part.
The test that lied#
My original verification poked Unbound directly and looked great:
$ dig @127.0.0.1 -p 2054 dnssec-failed.org | grep status
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 2659While fact-checking this post I ran the same query against the port clients actually use:
$ dig @127.0.0.1 -p 53 dnssec-failed.org +noall +answer
dnssec-failed.org. 86307 IN A 96.99.227.255A SERVFAIL from the forwarder cannot be the origin of an A record. Something between the client and Unbound had resolved the bogus name itself and handed it out.
That something is BIND 9. Two config lines conspired. First, my named.conf deliberately sets
dnssec-validation no; - Unbound was supposed to be the validation boundary, so BIND was not
double-checking signatures (BIND’s own stock default is auto).
Second, the config said forwarders { 127.0.0.1 port 2054; }; and nothing else, and BIND’s
documented default for the forward policy is first, not only:
A value of
firstis the default and causes the server to query the forwarders first; if that does not answer the question, the server then looks for the answer itself.
So whenever Unbound answers SERVFAIL, times out, or is down, BIND quietly resolves the name on its own, iteratively from the root hints, over ordinary DNS on UDP/TCP port 53. Exactly the traffic the whole design exists to prevent, triggered by the exact class of answer a validating upstream produces, and I found no corresponding fallback event in the default logs. The chain failed open, straight into the ISP-visible path, and every per-component test passed while it happened.
The fix is one line next to the forwarders:
options {
forward only; # never fall back to plaintext recursion
forwarders { 127.0.0.1 port 2054; };
};Two traps worth naming:
forward onlyfails closed: if Unbound or all four DoT upstreams are truly gone, external DNS is unavailable instead of degraded, while internal zones and already-cached answers keep working. For this threat model that is the correct trade, and serve-expired already cushions short outages. Say it out loud in your runbook either way.- A stock BIND would have hidden the symptom, not the leak: with the default
dnssec-validation autoit validates the answer it fetched itself and returns SERVFAIL, so the dig test looks clean while the plaintext fallback keeps happening. My explicitnois what made the leak visible. A clean test result and an open leak at the same time is the worst outcome on this page.
The moral: test the port your clients use, not the component you are proud of.
Verify the client-facing path#
dig @127.0.0.1 -p 53 dnssec-failed.org # must SERVFAIL
dig @127.0.0.1 -p 53 cloudflare.com # must NOERROROne catch before trusting the result: the pre-fix bogus record may still be cached
(mine sat there with a day-long TTL, and the dnsdist packet cache keeps entries for up to
86400 s). Flush first (rndc flush for BIND 9; restarting dnsdist clears its packet cache),
or query a label nobody has asked for yet: test-$(date +%s).dnssec-failed.org.
Four layers of cache, on purpose#
Yes, there is caching at every layer. That is intentional, and each layer caches something different:
| Layer | What it holds | Size | TTL behavior |
|---|---|---|---|
| dnsdist | full response packets | 200k entries | upstream TTL, capped at 86400 s |
| Pi-hole FTL | dnsmasq answers + block answers | 10k entries per instance | upstream TTL; blockTTL set to 60 s |
| BIND 9 | forwarded lookups (zones are not cache) | up to 512 MB | TTL floor raised to 90 s |
| Unbound | messages + rrsets, prefetch, serve-expired | 256 MB + 512 MB | serve-expired up to 3600 s |
The 60 s is Pi-hole’s dns.blockTTL, raised from the 2 s default; the flip side is that an
allowlist change can take up to a minute to reach clients.
The honest downside of layered caching is layered staleness.
min-cache-ttl 90 in BIND 9 pins even 30-second CDN records for a minute and a half, and the
outer layers can keep an answer past its original TTL; how long exactly depends on cache-fill
timing, the configured TTL floors, and serve-expired behavior.
For a homelab I take that trade without thinking; for anything latency-sensitive to DNS
changes, know your floors.
Measured from a client, warm vs cold (dig Query time, July 2026):
| Query | Time |
|---|---|
| repeat query (dnsdist packet cache hit) | 0 ms |
| blocked domain | 0 ms |
| internal zone name | 0 ms |
| cold external miss, full chain + DoT | 30-130 ms typical, 400 ms worst |
The cold number is dominated by the DoT round trip to the upstream, which is the price of not letting the ISP read the query.
IPv6 is off on purpose#
The chain is IPv4-only by design, via three knobs that do different things:
named -4and Unbound’sdo-ip6: noforce resolver transport over IPv4- BIND 9’s
filter-aaaaplugin strips AAAA records from unsigned answers to IPv4 clients - Pi-hole’s
resolveIPv6setting only disables reverse lookups of IPv6 client addresses
My LAN is IPv4-only, so for me this removes an entire class of “why does this device prefer
broken IPv6” debugging. If your network actually routes IPv6, delete the first two before you
wonder where your AAAA records went. ISC recommends against filter-aaaa unless you really
need it.
Do not become an open resolver#
Two rules I consider non-negotiable for any recursive resolver you build:
Restrict who can query you. dnsdist’s shipped default ACL is already loopback plus RFC1918, which is sensible. If you touch it, prefer
setACL(replaces) overaddACL(appends), and keep it to your real prefixes:-- dnsdist.conf: replace the ACL, never widen it setACL({ "127.0.0.0/8", "192.168.0.0/16" })Filter the backend ports too. With
network_mode: host, Pi-hole’s FTL binds the wildcard address by default, and its listening mode filters who gets answered, not what is bound. A firewall rule keeping:9991-:9997loopback-only closes the path around the load balancer. Manage those rules as code like everything else (my firewall lives in Terraform).
A publicly reachable recursive resolver will be found and abused within hours; RFC 5358 has been saying so since 2008.
Performance tuning: what is real and what is folklore#
The host gets a small sysctl set from the role: bigger socket buffers
(net.core.rmem_max/wmem_max at 4 MiB), deeper backlogs, and TCP tweaks
(tcp_tw_reuse, tcp_fastopen) aimed at connection setup and reuse.
Service-side, BIND 9 and Unbound both run one worker thread per vCPU.
Full disclosure: these values came from the usual tuning guides, and at household query rates
I could not demonstrate a measurable benefit from most of them. The knobs that provably
matter here are the cache sizes, serve-expired, and prefetch. The rest is hygiene for a
load profile this VM will never see, kept because it costs nothing and the role makes it
reproducible.
Ansible as a contract: deploy, validate, prove#
I do not trust a DNS deploy that does not validate itself. The role enforces that in two stages.
Configs cannot reach a daemon unvalidated. Every template lands through an Ansible
validate: step: named-checkconf -z and named-checkzone for BIND 9,
unbound-checkconf for Unbound, dnsdist --check-config for the balancer.
A typo fails the deploy, not the resolver.
The deploy ends with live end-to-end assertions on the client-facing port, including the negative test from earlier, because the whole point of that story is that positive tests lie:
- name: verify the chain end to end
command: "dig @127.0.0.1 -p 53 {{ item.name }} +noall +comments +answer"
register: check
changed_when: no
failed_when: item.expect | reject('in', check.stdout) | list | length > 0
retries: 5
delay: 10
until: check is not failed
loop:
- { name: "google.com", expect: ["status: NOERROR", "\tA\t"] } # recursion returns an A record
- { name: "ns.lab.internal", expect: ["status: NOERROR", "192.168."] } # internal zone answers
- { name: "dnssec-failed.org", expect: ["status: SERVFAIL"] } # bogus data rejectedEvery item asserts on the rcode and, for the positive cases, on the answer content.
One subtlety that bit me: dig exits 0 for SERVFAIL, REFUSED, and empty answers, so a check
that only looks at the return code proves nothing beyond “something spoke DNS on that port”.
Assert on the output, not the exit code.
This turns “I think I deployed DNS” into “I can prove what the chain does end to end”, and the deploy itself is fast enough to be boring (Mitogen helps).
FAQ#
Do I need multiple Pi-hole instances?#
No. One Pi-hole handles a normal household with headroom. I run seven for restart isolation and to roll changes across backends one at a time, and dnsdist makes the count invisible to clients anyway.
Why not just Pi-hole plus Unbound?#
That stack is the right answer for most people. I wanted two things it does not give me: internal authoritative zones generated from the Ansible inventory, and a health-checked pool where any single backend can die or restart without anyone noticing.
Does dnsdist replace Pi-hole?#
No. dnsdist is a DNS load balancer and cache; it has no blocklists and no policy. Pi-hole is the policy layer. They compose instead of competing.
Why does Unbound forward instead of doing full recursion?#
Threat model. Full recursion means plaintext queries to authoritative servers all over the internet, visible to anyone on the path. Forwarding over authenticated DoT means the ISP sees only TLS to a handful of resolver IPs. Recursion is the more self-sufficient design; this one trades that for privacy.
Was it worth it?#
Yes, it is overengineering, and I said so in the title. But it is the kind that buys what I actually care about: every device filtered without touching it, internal names that come from the inventory instead of a wiki page, upstream DNS the ISP cannot read or rewrite, and a deploy that proves all of the above every time it runs. And the fact-check for this very post caught the one silent hole in it, which is the strongest argument for writing things down that I know.

