$ cat /blog/pihole-unbound-dns-pair.mdx

·10 min read

Pi-hole + Unbound on two Raspberry Pis: DNS the whole household depends on

How I turned two of the four Pis into a redundant DNS pair — Pi-hole blocking ads, Unbound recursing directly to root servers, zero ISP resolver seeing my queries. Plus the rolling upgrade playbook that gates each Pi behind a dig health check so the house never loses DNS.

The first service I put on the Pis was DNS — because every homelab guide points there first. Small enough to reason about, big enough to be useful, standard on-ramp for a reason.

What kept me interested past the “checkbox project” stage was two things I hadn’t quite expected. Ads disappear from every device on the network, not just the ones with a browser ad-blocker installed — smart TV, mobile games, that random IoT gadget that phones home 2000 times a day. And DNS gets faster than my ISP’s resolver once Unbound’s local cache is warm. Noticeably faster.

Then the practical bit that made the design decisions for me: every device in the house depends on this — phones, laptops, TVs, doorbell. One Pi rebooting for a kernel update takes the whole house offline for a couple of minutes. So this needed to be redundant from day one. Two Pis. If either dies, DNS keeps working. Nobody notices when I’m tinkering.

This is that setup — Pi-hole for ad-blocking, Unbound for recursive resolution, one running on each of two of the baseline-ready Pis from post 3. Plus the small Ansible playbook I wrote to roll out Pi-hole image upgrades without dropping a single query.

What we’re building

Two Raspberry Pis, both running the same DNS stack, both serving the whole household. From the network’s perspective:

  • DHCP hands out both Pis as DNS servers (primary + secondary) to every device on the LAN
  • Every device queries the primary first; if it doesn’t answer within a couple of seconds, they retry the secondary
  • Ads get blocked at the DNS level — no per-device ad-blocker needed
  • Query resolution happens locally, not via 8.8.8.8 or 1.1.1.1. My ISP doesn’t see my DNS traffic. Nor does Google or Cloudflare.

If you’re new to the vocabulary:

  • DNS turns naysync.com into an IP address your browser can actually connect to. Every request to every website starts with a DNS lookup.
  • Pi-hole is a small server that sits between your devices and the actual DNS resolvers, and refuses to answer for domains that serve ads or tracking. Instead of returning googleadservices.com → 142.250.x.x, it returns “no such domain,” and the ad quietly fails to load.
  • Unbound is a recursive DNS resolver — instead of forwarding your queries to a big upstream (Google, Cloudflare, your ISP), it climbs the DNS tree itself, starting from the root servers, and gets you the answer directly.

Pi-hole handles blocking. Unbound handles the actual resolution behind it. Two containers per Pi, working together.

Why Pi-hole

Alternatives I looked at:

  • Just point everything at 1.1.1.1. Fast, simple, no infra. Doesn’t block ads, and Cloudflare sees every query. Fine if you don’t care.
  • Browser-level ad-blockers (uBlock Origin, etc.). Works only in browsers. Doesn’t help my TV, my thermostat, or the ad in a mobile game. Also doesn’t help against the tracking that happens before the ad even loads.
  • AdGuard Home. Close cousin of Pi-hole, similar feature set. I don’t have a strong preference — I went Pi-hole because I’d used it before, and the community is huge, so any weird question has been asked before.
  • Router-level ad-blocking (if your router supports it). Fine for a single-site household. Doesn’t scale to two nodes for redundancy without more router-vendor lock-in.

Pi-hole won on: network-wide (helps every device), a nice admin UI with real stats, open source, and it runs on a Raspberry Pi cheerfully.

Why Unbound behind it

By itself, Pi-hole doesn’t actually resolve DNS — it just filters queries and forwards the rest somewhere else. That “somewhere else” is called the upstream. Options:

  • Public resolvers (8.8.8.8 Google, 1.1.1.1 Cloudflare, 9.9.9.9 Quad9). Fast, easy. But every domain you visit gets logged by whoever runs the resolver. That’s the trade for speed.
  • Your ISP’s default. Same problem, plus most ISPs’ resolvers are slow and cache aggressively.
  • A local recursive resolver like Unbound. You talk to the root DNS servers yourself, follow the referral chain, get the answer, cache it. No third party in the middle.

Unbound is small (under 50 MB of RAM), well-maintained, and does DNSSEC validation out of the box. First query for a domain is slightly slower than hitting a warm public cache; subsequent queries are as fast as the cache. Net: unnoticeable in daily use.

The trade I made: a bit more setup complexity in exchange for no third party seeing my DNS traffic at all. On paper it’s a privacy improvement; in practice it’s mostly just satisfying to know.

Why two Pis

The whole point. A single Pi-hole is a single point of failure. When:

  • The SD card dies (see post 3)
  • I reboot to update
  • The power blips
  • Something eats port 53 by accident

the house’s internet quietly stops working. For 2 minutes if I’m on top of it, 20 minutes if I’m asleep. Not fine.

Two Pis running the same DNS stack, plus DHCP configured to hand out both, means:

  • Client devices fail over automatically after ~2 seconds if the primary doesn’t answer
  • I can update, reboot, rebuild, or replace either Pi without touching the other
  • Both Pis are on the same power circuit (so a proper power outage takes them both down — but at that point DNS isn’t my biggest problem)

Not fancy. Not high-availability in the enterprise sense. Just “one of them can go missing and nobody notices.”

The stack, one Pi at a time

Each Pi runs the same Docker Compose file with two containers: Unbound and Pi-hole. Both use host networking (necessary — DNS needs to listen on port 53 on the LAN, and Docker’s default bridging gets in the way).

The Unbound container listens on 127.0.0.1:5335 (not the standard 53 — that’s Pi-hole’s job). Pi-hole is configured to send resolved queries to Unbound at 127.0.0.1#5335.

Interesting detail: Pi-hole is also configured to fall back to the peer Pi’s Unbound if the local one somehow times out. So each Pi has two upstreams:

  1. 127.0.0.1#5335 — local Unbound
  2. <peer-pi-ip>#5335 — the other Pi’s Unbound

Belt and braces. If Pi-A’s Pi-hole is up but its Unbound has locked up, queries still resolve via Pi-B’s Unbound. Uncommon but cheap insurance.

The Unbound config itself is boring on purpose:

server:
    interface: 127.0.0.1
    port: 5335
    do-ip4: yes
    do-udp: yes
    do-tcp: yes
    do-ip6: no

    harden-glue: yes
    harden-dnssec-stripped: yes
    use-caps-for-id: no

    edns-buffer-size: 1232
    prefetch: yes
    num-threads: 1

    private-address: 192.168.0.0/16
    private-address: 172.16.0.0/12
    private-address: 10.0.0.0/8
    private-address: fd00::/8
    private-address: fe80::/10

The private-address block is worth calling out: it tells Unbound to never return a private-range IP as an answer for a public-looking domain. This is a defense against DNS rebinding attacks — where a malicious website tries to trick your browser into thinking one of your internal services (say 10.0.0.5, your router) belongs to them. Nice to have on a homelab that has real internal services.

The Pi-hole side is nearly stock. Two environment variables that matter:

  • FTLCONF_dns_upstreams=127.0.0.1#5335;<peer-pi-ip>#5335 — local first, peer second
  • FTLCONF_dns_dnssec=true — validate DNSSEC signatures (Unbound also does this — both do it, belt and braces again)

Ansible deploys both containers via docker-compose-v2. The role also templates in a homelab.local domain suffix for local hostnames, sets up a pihole-exporter sidecar container for Prometheus metrics, and mounts /sys/class/thermal/thermal_zone0/temp into the Pi-hole container so I can watch the Pi’s CPU temp from Grafana. Small nice-to-haves.

Handing out both DNS servers via DHCP

The last mile is the router. On the LAN, DHCP is configured to hand out both Pi IPs as DNS servers to every device:

Primary DNS:   <pi-1-ip>
Secondary DNS: <pi-2-ip>

That’s the whole client-side story. No per-device configuration. New device joins the WiFi → gets an IP + both DNS servers → done. Ad-blocking active, DNSSEC validated, ISP not in the loop.

(How you set this depends on your router — most home routers have a “DHCP settings” page with primary/secondary DNS fields. On some carrier-supplied routers this is unfortunately locked; if that’s you, either replace the router or run your own DHCP server on one of the Pis.)

The rolling upgrade — the good part

Pi-hole ships new container images every couple of months. Each release bumps FTL (the DNS engine underneath), fixes bugs, updates the block-list plumbing, etc. I want the updates. What I don’t want is a DNS outage while I pull the new image.

The Ansible role that deploys Pi-hole is set to pull: missing — meaning it only pulls the image if it doesn’t already exist locally. That’s deliberate: re-running the deploy playbook shouldn’t accidentally roll out a new image behind my back.

To actually roll out a new image, there’s a separate playbook: pihole_image_update.yml. What it does, in order:

serial: 1  # one Pi at a time

For each Pi (starting with the primary):

  1. Record the current pihole image ID — for the before/after diff at the end.
  2. docker pull pihole/pihole:latest with force_source: true — actually fetch the new image if latest has moved.
  3. If the image digest actually changed, recreate the pihole container (leaving unbound alone — no need to touch a working service).
  4. Wait up to 60 seconds for the container to report Running: true.
  5. Pause 10 seconds for FTL to bind port 53 fully.
  6. From the control machine, run dig +short google.com @<this-pi-ip> — up to 6 retries with 5-second waits.
  7. If dig doesn’t return an answer, the play fails and stops. The next Pi is never touched.
  8. Report before/after image SHAs + pihole -v output for the log.

The critical bit is step 7. If pihole-p’s upgrade breaks, the playbook halts before touching pihole-s. Which means the household’s DNS still works — pihole-s is still on the old, working version. I get a red terminal, but nobody in the house notices anything.

I actually tested this once by deliberately breaking the primary mid-upgrade (blocked port 53 with iptables for 20 seconds). Ran the playbook. The dig gate correctly failed, the playbook stopped, DNS on the secondary was fine, family was watching Netflix, oblivious.

Verifying it works from the outside

The most useful thing I did while setting this up was a small hammer-DNS test. From my laptop:

while true; do
  dig +noall +stats +time=2 +tries=1 example.com \
    | grep -E 'Query time|status'
  sleep 0.5
done

Run that in one terminal. In another, do something disruptive — reboot a Pi, run the image-upgrade playbook, unplug a network cable. Watch how the query times respond.

Steady state on the working Pi: ~20 ms per query. When I sudo reboot one Pi:

  • ~2 seconds of one query timing out (~1900 ms), as my Mac’s resolver notices the primary is unresponsive
  • Then steady sub-30 ms queries via the secondary for the rest of the reboot cycle
  • Nothing noticeable in a browser, no complaint from the couch

Do this test before you call the setup done. It’s the difference between “in theory redundant” and “actually redundant.”

What I get out of this

Concrete daily wins:

  • Ad-free browsing on every device, including devices I can’t install an ad-blocker on (TV, phone apps, guest devices)
  • A Pi-hole admin dashboard that tells me which client on the network is querying the most (fun to spot the smart TV phoning home 2000 times a day)
  • No ISP or public DNS provider sees my query stream
  • Zero notice-able outages when I update, reboot, or rebuild either Pi

Concrete costs:

  • Two Raspberry Pis of ongoing maintenance — mostly OS updates, staggered across the two so they’re never both down
  • One extra thing to think about when the SD card health gets flagged (see the SD-card note in post 3)
  • The very rare “hmm, this website looks broken” — usually turns out to be Pi-hole blocking a tracking script that a page shouldn’t have required anyway. Whitelisting takes 10 seconds.

What comes next

The DNS pair was the first service that had a real dependent (the house). The next few posts step up the dependent tree:

  • The VPN gateway + torrenter pinning — how one VM’s traffic is forced through a commercial VPN tunnel, and how nothing else on the network touches it
  • The monitoring stack — Prometheus + Grafana + Loki on the monitor VM, watching everything else, including this DNS pair
  • The Home Assistant Pi — the 8 GB Pi with the USB-SSD boot from post 3, running everything smart in the house

Any of those next. If you have a preference — reply to whichever dispatch you got in your inbox and I’ll pick that one.


$ cd /blog  // all posts