
A three-VM HA DNS + ad-block: primary Pi-hole, secondary failover, Unbound recursive resolver
May 29, 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.
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.
pihole-primary (10.78.0.10:53/80) — the main DNS server + admin UI, with FTL config already pointed at Unbound.pihole-secondary (10.78.0.20:53/80) — identical shape, ready for gravity-sync replication.unbound (10.78.0.30:5335) — recursive resolver so neither Pi-hole asks 1.1.1.1 / 8.8.8.8 for every lookup.pihole-FTL.conf.example already points at the local Unbound, and the failover runbook is on disk for the secondary.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.
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./etc/pihole and the FTL database; rebuild from these if you ever flatten a VM.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.