Skip to main content
Overengineering Homelab DNS: dnsdist, Pi-hole, Unbound

Overengineering Homelab DNS: dnsdist, Pi-hole, Unbound

··3193 words·15 mins
Stanislav Cherkasov
Author
Stanislav Cherkasov
{DevOps,DevSecOps,Platform} Engineer
Table of Contents
homelab - This article is part of a series.
This Article

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
#

ComponentListens onJob
dnsdist 1.9:53entry point: load balancing, health checks, packet cache
Pi-hole x7 (FTL v6):9991-:9997blocklists (4.79M domains), per-client policy, local cache
BIND 9.20127.0.0.1:1053internal authoritative zones, rendered from Ansible inventory
Unbound 1.22127.0.0.1:2054caching 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 /32 ECS 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-addr carries a #hostname suffix and tls-cert-bundle points 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:

  1. 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.
  2. 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: 2659

While 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.255

A 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 first is 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 only fails 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 auto it validates the answer it fetched itself and returns SERVFAIL, so the dig test looks clean while the plaintext fallback keeps happening. My explicit no is 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 NOERROR

One 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:

LayerWhat it holdsSizeTTL behavior
dnsdistfull response packets200k entriesupstream TTL, capped at 86400 s
Pi-hole FTLdnsmasq answers + block answers10k entries per instanceupstream TTL; blockTTL set to 60 s
BIND 9forwarded lookups (zones are not cache)up to 512 MBTTL floor raised to 90 s
Unboundmessages + rrsets, prefetch, serve-expired256 MB + 512 MBserve-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):

QueryTime
repeat query (dnsdist packet cache hit)0 ms
blocked domain0 ms
internal zone name0 ms
cold external miss, full chain + DoT30-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 -4 and Unbound’s do-ip6: no force resolver transport over IPv4
  • BIND 9’s filter-aaaa plugin strips AAAA records from unsigned answers to IPv4 clients
  • Pi-hole’s resolveIPv6 setting 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:

  1. 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) over addACL (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" })
  2. 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-:9997 loopback-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 rejected

Every 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.

References
#

homelab - This article is part of a series.
This Article

Related