OpenFactory Pi-hole DNS cluster with primary, secondary failover and Unbound recursive resolver

Build a Pi-hole DNS Cluster with Unbound on OpenFactory

A three-VM HA DNS + ad-block: primary Pi-hole, secondary failover, Unbound recursive resolver

May 29, 2026

← Back to Blog

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.

What you'll build

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

Why build it on OpenFactory

  • The ISO is the spec. pihole-FTL.conf.example already points at the local Unbound, and the failover runbook is on disk for the secondary.
  • Two Pi-holes from day one. Single-Pi-hole is a single point of failure for the whole household network. The recipe ships HA because you should.
  • Unbound by default. Recursive resolution means upstream DNS providers can't correlate your whole query stream.
  • Scenario assertions ride along. The build fails closed if either Pi-hole reports the wrong status, if Unbound isn't reachable, or if the gravity-sync notes are missing.

Topology

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.

The prompt

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.

Running it

  1. Open the chat builder at console.openfactory.tech and paste the prompt into a new conversation.
  2. Review the streamed build plan. You'll see the topology, per-node recipes, and the scenario assertions that will run after boot. Edit the prompt and re-run if anything is off.
  3. Click Build group. OpenFactory fans the plan out to per-node ISO builds. When every ISO reaches built, boot the group on the runner network from the same UI.
  4. Exercise the stack. The scenario assertions run automatically against the live VMs. From the host you can also hit the service ports directly to confirm end-to-end behavior.

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.

What's still your responsibility

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:

  • Real Pi-hole install. Run the upstream installer — FTL + admin LTE — on top of the env files and FTL config that are already in place.
  • Real Unbound. Install unbound on top of the config in /etc/unbound/unbound.conf.d/pi-hole.conf; DNSSEC validation belongs inauto-trust-anchor-file.
  • Gravity sync. Set up gravity-sync or orbital-sync per the on-disk runbook to replicate blocklists primary -> secondary.
  • Client DHCP option 6. Point clients at both Pi-holes via your router's DHCP server. Without this the cluster does nothing.
  • Blocklist subscriptions. The stock blocklists are a reasonable start; tune to taste.
  • Off-host backups. /etc/pihole and the FTL database; rebuild from these if you ever flatten a VM.

Where to go next

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.

Ready to ship this in production?

OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.