
A three-VM HA DNS + ad-block: primary Pi-hole, secondary failover, Unbound recursive resolver
April 25, 2026
Pi-hole is the network-wide DNS sinkhole that blocks ads, telemetry, and tracker domains for every device on the LAN — phones, TVs, refrigerators — without an agent on any of them. It's the ninth-most-deployed app in the 2026 r/selfhosted survey, with AdGuard Home gaining but Pi-hole still ahead.
The architecture is also simpler than it used to be. Pi-hole v6 folded the web interface and a full REST API directly into the pihole-FTL binary and dropped the lighttpd and PHP dependencies entirely, consolidating every setting into a single commented /etc/pihole/pihole.toml file with native HTTPS for the admin UI (Pi-hole v6 announcement). The February 2026 release (FTL v6.5) went further on performance: gravity blocklist updates got ~16% faster in real time and a new database.forceDisk option trims FTL's memory on small hardware (v6.5 release notes).
This post walks through the HA Pi-hole pattern on OpenFactory: three buildable VMs — a primary Pi-hole, a secondary for failover, and a recursive Unbound resolver behind both — from one prompt, shipped as bootable ISOs. DNS is the one service whose outage takes the entire household offline — no name resolution, no streaming, no doorbell — so it is the textbook case for building redundancy in from the start rather than bolting it on after the first outage.
pihole-primary (10.78.0.10:53/80) — the main DNS server + admin UI, with FTL config already pointed at Unbound. In production this is the VRRP master that normally owns the virtual IP your clients actually query.pihole-secondary (10.78.0.20:53/80) — identical shape, ready for config replication. It sits in VRRP backup and promotes itself to master the instant the primary stops answering.unbound (10.78.0.30:5335) — recursive resolver so neither Pi-hole asks 1.1.1.1 / 8.8.8.8 for every lookup. It walks the DNS hierarchy from the root itself, so no single upstream provider sees your whole query stream.pihole-FTL.conf.example already points at the local Unbound, and the failover runbook is on disk for the secondary.pihole.toml, the API and UI served by FTL itself, no lighttpd or PHP to patch. The recipe ships the shape upstream already moved to.Three Debian Trixie VMs on 10.78.0.0/24. Both Pi-holes forward to the single Unbound; clients point at one Pi-hole as primary and the other as secondary in their DHCP option 6. In a fuller HA build you go one step further and float a single virtual IP across the pair with keepalived (VRRP) — clients then chase one address and never notice which physical Pi-hole is answering.
Under VRRP, the two Pi-holes exchange a periodic heartbeat and elect a master; the master holds the virtual IP and answers every query, while the backup waits silently. When the master stops advertising — a reboot, a crash, an unplugged cable — the backup promotes itself and claims the VIP, typically within a few seconds, with no client reconfiguration. Homelab operators running exactly this stack report months of no unplanned DNS downtime, at the cost of a modest latency trade versus querying a public resolver directly — roughly 15 ms for cached answers and ~80 ms for a cold recursive lookup (keepalived + Unbound write-up).
Keeping the two in sync is the other half. The VIP handles availability; it does nothing for consistency. Blocklists, allowlists, and group rules are replicated separately — historically with gravity-sync, and on Pi-hole v6 with tools like orbital-sync / nebula-sync that watch the primary and push changes to the backup on a short interval. The recipe leaves both runbooks on disk so you wire up whichever you prefer.
Paste this verbatim into the chat builder at console.openfactory.tech. Nothing above or below it — the builder expects the prompt body to start at the “Build a compact multi-node lab…” line.
Build a compact multi-node lab named `pihole-dns-cluster`.
Output discipline: keep the plan small. Use one startup script per node, about 25 shell lines or less. Do not install the real Pi-hole, dnsmasq, lighttpd, PHP, or Unbound binaries at build time. Do not fetch blocklists during the build. Write deployment-time config examples and tiny Python stdlib or shell compatibility stubs only. The goal is a buildable preparation lab, not a production deployment.
## Topology
Create 3 buildable `debian-trixie` nodes, all `x86_64`, SSH enabled, DHCP/default route intact with lab aliases, firewall disabled, DNS `1.1.1.1` and `8.8.8.8`, user `ops` password `pihole-ops` in `sudo`. Every recipe must set top-level `test_config` to `{ "enabled": false, "tests": [] }`.
- `pihole-primary`: role `dns-primary`, 1 GB RAM, 12 GB disk, alias `10.78.0.10/24`, x `110`, y `60`
- `pihole-secondary`: role `dns-secondary`, 1 GB RAM, 12 GB disk, alias `10.78.0.20/24`, x `350`, y `60`
- `unbound`: role `recursive-resolver`, 1 GB RAM, 8 GB disk, alias `10.78.0.30/24`, x `230`, y `220`
Connections: `pihole-primary` and `pihole-secondary` to `unbound:5335`; clients to either Pi-hole on `:53`.
## Common Recipe Requirements
All nodes: features `headless`, `ssh`; packages `openssh-server`, `python3`, `curl`, `jq`, `iproute2`, `netcat-openbsd`, `ca-certificates`. Each startup script adds the alias with `IFACE=$(ip route show default | awk '{print $5; exit}')`, `ip link set "$IFACE" up || true`, and `ip addr add <alias> dev "$IFACE" || true`. If `os.startup_scripts[].after` is present, it must be the string `"network-online.target"`, not an array.
## Node Requirements
`pihole-primary` and `pihole-secondary` are near-identical compatibility services with different host identifiers. Each writes `/etc/pihole/pihole-FTL.conf.example` with `LOCAL_IPV4=<alias>`, `PIHOLE_DNS_1=10.78.0.30#5335`, `BLOCKING_ENABLED=true`. Each adds a Python stdlib HTTP service on `0.0.0.0:80` exposing:
- `GET /admin/api.php?summary` -> `200 {"status":"enabled","domains_being_blocked":"compat","dns_queries_today":"0","ads_blocked_today":"0"}`
- `GET /admin/api.php?version` -> `200 {"version":"compat-1.0","branch":"main","tag":"compat"}`
- `GET /metrics` -> `pihole_compat_up 1` plus a label line `pihole_node{role="primary|secondary"} 1`
And a Python stdlib UDP/TCP listener on `0.0.0.0:53` that opens the socket and accepts connections (no DNS protocol implementation needed). Register `pihole-compat.service`. The primary writes `/root/pihole-gravity-sync-notes.md` documenting how the real deployment would replicate `/etc/pihole/gravity.db` to the secondary; the secondary writes `/root/pihole-secondary-notes.md` describing the failover wiring.
`unbound`: features `headless`, `ssh`. Write `/etc/unbound/unbound.conf.d/pi-hole.conf`:
```
server:
verbosity: 0
port: 5335
interface: 0.0.0.0
do-ip4: yes
do-udp: yes
do-tcp: yes
use-systemd: yes
```
Add a Python stdlib listener on `0.0.0.0:5335` accepting TCP connections plus an HTTP `:9167/metrics` listener with `unbound_compat_up 1`. Register `unbound-compat.service`.
## Scenario
Emit exactly one group scenario named `pihole-dns-cluster-validation`. Put `custom_tests[].assertions[]` inside the scenario entry; leave `scenarios[].tests` empty. Every assertion needs `on_vm`. Use only `port_listening`, `command_output`, and `http_responds`; do not emit `vm_boots`, `network_reachable`, or `service_running`.
- `Stack ports listen`: `port_listening` for `pihole-primary:53`, `pihole-primary:80`, `pihole-secondary:53`, `pihole-secondary:80`, `unbound:5335`, `unbound:9167`.
- `Pi-hole primary summary`: on `pihole-primary`, `curl -fsS 'http://localhost/admin/api.php?summary' | jq -e '.status == "enabled"' >/dev/null && echo primary-ok`.
- `Pi-hole secondary summary`: on `pihole-secondary`, `curl -fsS 'http://localhost/admin/api.php?summary' | jq -e '.status == "enabled"' >/dev/null && echo secondary-ok`.
- `Both reach Unbound`: on `pihole-primary`, `nc -z -w 5 10.78.0.30 5335 && echo unbound-reachable`; on `pihole-secondary`, the same check.
- `Failover notes present`: on `pihole-primary`, `test -s /root/pihole-gravity-sync-notes.md && echo gravity-notes`; on `pihole-secondary`, `test -s /root/pihole-secondary-notes.md && echo secondary-notes`.
Preserve warnings that real Pi-hole installer (FTL + admin LTE), upstream DNSSEC validation in Unbound, gravity.db replication between primary and secondary, blocklist subscription management, client device DHCP option 6 pointing at the cluster VIP, off-host backups, and `10.78.0.0/24` aliasing are deployment-time concerns.Driving OpenFactory from an AI agent instead of the browser? The same flow is exposed through the OpenFactory MCP server — submit the prompt programmatically, get the build-plan preview back, and call create_build / start_vm on the resulting recipes. Single-image builds go straight through the openfactory CLI.
The prompt produces a buildable preparation lab — the right topology, the right ports listening, deployment-time config templates dropped in the right places, and tiny compatibility services that prove the wiring works. A few things still sit outside the recipe and need operator attention before this carries real load:
unbound on top of the config in /etc/unbound/unbound.conf.d/pi-hole.conf; DNSSEC validation belongs inauto-trust-anchor-file.gravity-sync or orbital-sync per the on-disk runbook to replicate blocklists primary -> secondary.keepalived, picking a VRID and priorities, choosing the VIP address — is the deployment-time step that turns “two servers” into one always-on address.cloudflared forwarder, or AdGuard Home's built-in encrypted modes) — an explicit choice, not a recipe default./etc/pihole and the FTL database; rebuild from these if you ever flatten a VM.Pi-hole or AdGuard Home in 2026? Both are excellent, and the trade is real. AdGuard Home ships as one binary with built-in DNS-over-HTTPS, -TLS, and -QUIC and native per-client filtering. Pi-hole wins on the larger blocklist community and the deep Unbound recursive-privacy ecosystem this lab is built around (Pi-hole vs AdGuard 2026). The topology here ports cleanly to either.
Why Unbound instead of just forwarding to 1.1.1.1? Forwarding hands your entire query history to one company. Unbound resolves recursively from the root, so no single upstream sees the whole picture — the privacy reason the recipe puts it behind both Pi-holes by default.
Is two Pi-holes really worth it at home? When DNS dies, everything looks broken to every device at once — and it always seems to happen mid software-update or while you're out. A second VM and a VRRP heartbeat is cheap insurance against your whole network going dark during a single reboot.
Private DNS pairs naturally with a private VPN. See the own-your-VPN post. And the private home VPN post shows the pattern from the other end. For the compliance story under DNS infrastructure, the Enterprise & GxP page is the way in.
OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.