Resilient DNS at Home: Building an HA Pi-hole Pair on Raspberry Pi
DNS is one of those services you don’t notice until it stops working — at which point the entire household notices, very loudly, all at once. A single Pi-hole on a Raspberry Pi is a fantastic ad-blocker and local resolver, but it is also a single point of failure. SD card dies, you reboot for a kernel update, the Pi falls off the network — and suddenly nobody can reach Netflix, the smart bulbs go dumb, and you’re explaining DNS to people who do not want to hear about DNS.
This guide walks through two things, in order:
- Building a clean, well-configured Pi-hole on a single Raspberry Pi running current Raspbian (Bookworm-based Raspberry Pi OS).
- Adding a second Pi-hole and turning the pair into a resilient, highly-available DNS service using
keepalivedfor a virtual IP and Orbital Sync for configuration replication.
Everything below assumes you have two Raspberry Pi boards (Pi 4 or Pi 5 ideal, but a 3B+ is fine for home use), each with a fresh install of Raspberry Pi OS Lite (64-bit) and SSH access. I’ll call them pihole-a and pihole-b, on a 192.168.1.0/24 LAN — adjust to taste.
Architecture
What we’re building, end state:
┌─────────────────────────────┐
│ Clients (LAN, DHCP) │
│ DNS server: 192.168.1.53 │ ← Virtual IP (VIP)
└──────────────┬──────────────┘
│
┌─────────────┴─────────────┐
│ keepalived (VRRP) │
└─────┬───────────────┬─────┘
│ │
┌────────────▼──┐ ┌──▼───────────┐
│ pihole-a │ │ pihole-b │
│ 192.168.1.51 │ ◄─sync─►│ 192.168.1.52 │
│ MASTER (100) │ │ BACKUP (90) │
└───────┬───────┘ └───────┬──────┘
│ │
└────────► upstream ◄─────┘
(1.1.1.1, 9.9.9.9, etc.)
Clients only ever know about 192.168.1.53. keepalived decides which Pi owns it at any given moment. Orbital Sync makes sure that whichever box answers, you get the same blocklists, allowlists, and local DNS records.
Stage 0 — Prerequisites
On each Pi, before doing anything else:
# Update and reboot
sudo apt update && sudo apt full-upgrade -y
sudo apt install -y curl ca-certificates git
sudo reboot
Set static IPs. On Bookworm, networking is managed by NetworkManager, not the old dhcpcd.conf. The clean way is nmcli:
# pihole-a (run on pihole-a)
sudo nmcli con mod "Wired connection 1" \
ipv4.method manual \
ipv4.addresses 192.168.1.51/24 \
ipv4.gateway 192.168.1.1 \
ipv4.dns "1.1.1.1 9.9.9.9"
sudo nmcli con up "Wired connection 1"
# pihole-b (run on pihole-b)
sudo nmcli con mod "Wired connection 1" \
ipv4.method manual \
ipv4.addresses 192.168.1.52/24 \
ipv4.gateway 192.168.1.1 \
ipv4.dns "1.1.1.1 9.9.9.9"
sudo nmcli con up "Wired connection 1"
Verify:
ip -4 addr show eth0
nmcli -t -f IP4.ADDRESS,IP4.GATEWAY,IP4.DNS dev show eth0
Set a sensible hostname on each:
sudo hostnamectl set-hostname pihole-a # or pihole-b
Add both to /etc/hosts on both Pis so they can find each other by name without depending on… themselves:
# /etc/hosts on both nodes
192.168.1.51 pihole-a
192.168.1.52 pihole-b
Stage 1 — Install Pi-hole on pihole-a
Pi-hole has a one-liner installer. It’s signed and well-maintained, but if you’d rather audit it first:
curl -sSL https://install.pi-hole.net -o pihole-install.sh
less pihole-install.sh
sudo bash pihole-install.sh
Or just run it directly:
curl -sSL https://install.pi-hole.net | sudo bash
The installer asks a series of questions. Answers I’d suggest for an HA setup:
| Prompt | Answer |
|---|---|
| Upstream DNS provider | Cloudflare (1.1.1.1) — or Custom and put both 1.1.1.1 and 9.9.9.9 |
| Block lists | Yes (the default StevenBlack list is fine) |
| Install web admin interface | Yes |
| Install web server | Yes (Pi-hole v6 uses its own built-in server — no more lighttpd) |
| Log queries | Yes |
| Privacy mode | 0 — Show everything (you can tighten later) |
| Static IP confirmation | Yes — you already set it |
When the installer finishes it prints an admin password. Copy it. If you miss it, set a new one:
sudo pihole setpassword
# Pi-hole v6 — replaces the old `pihole -a -p`
Sanity check:
pihole status
# Should show:
# ✓ FTL is listening on port 53 (...)
# ✓ DNS service is running
dig @192.168.1.51 example.com +short
# Should return one or more A records.
dig @192.168.1.51 doubleclick.net +short
# Should return 0.0.0.0 (or NXDOMAIN, depending on your block mode).
Web UI is at http://192.168.1.51/admin. Log in with the password from above.
Key Pi-hole v6 paths to know
/etc/pihole/pihole.toml # main config (replaces setupVars.conf)
/etc/pihole/gravity.db # SQLite — blocklists, allowlists, groups, clients
/etc/pihole/dhcp.leases # if you let Pi-hole serve DHCP
/var/log/pihole/pihole.log # query log
/var/log/pihole/FTL.log # daemon log
Stage 2 — Useful Pi-hole show commands
Worth committing to muscle memory:
pihole status # quick health check
pihole -c # live dashboard (chronometer)
pihole -t # tail the query log
pihole -q doubleclick.net # is this domain on a list? which one?
pihole -g # rebuild gravity (re-pull blocklists)
pihole -up # update Pi-hole itself
# FTL daemon
sudo systemctl status pihole-FTL
sudo journalctl -u pihole-FTL -f
# What's actually listening?
sudo ss -tulpn | grep -E ':(53|80|443|4711)\b'
# 53/udp,tcp = DNS
# 80/tcp = web admin
# 4711/tcp = FTL telemetry
If you want a quick summary from the CLI without opening the dashboard:
pihole-FTL --config stats.queries_total
echo ">stats" | nc -q1 localhost 4711
Stage 3 — Install Pi-hole on pihole-b
Repeat Stage 1 on the second Pi. Same upstreams, same blocklists, same answers. Don’t worry about copying configuration across yet — Orbital Sync will take care of that.
Verify it independently:
dig @192.168.1.52 example.com +short
dig @192.168.1.52 doubleclick.net +short
At this point you have two parallel, independent Pi-holes. You could stop here, hand both IPs to your DHCP server as primary/secondary, and call it a day — but you’d be unhappy with how DNS clients actually behave when “primary” goes away (most stub resolvers hold onto the primary stubbornly and only fall back after several timeouts). VIP failover is the better answer.
Stage 4 — Install keepalived on both Pis
keepalived implements VRRP. Both Pis advertise their willingness to own a virtual IP; the higher-priority one wins; if it stops advertising, the other takes over within a couple of seconds.
sudo apt install -y keepalived
We need the kernel to allow binding to a non-local IP, so that Pi-hole’s FTL can listen on the VIP even on the BACKUP node (it makes failover faster). On both Pis:
echo "net.ipv4.ip_nonlocal_bind = 1" | sudo tee /etc/sysctl.d/60-keepalived.conf
sudo sysctl --system
keepalived config — pihole-a (MASTER)
/etc/keepalived/keepalived.conf on pihole-a:
global_defs {
router_id pihole-a
script_user root
enable_script_security
}
vrrp_script chk_pihole {
script "/usr/local/sbin/check_pihole.sh"
interval 5
timeout 3
rise 2
fall 2
}
vrrp_instance VI_PIHOLE {
state MASTER
interface eth0
virtual_router_id 53
priority 100
advert_int 1
authentication {
auth_type PASS
auth_pass changeme-shared-secret
}
unicast_src_ip 192.168.1.51
unicast_peer {
192.168.1.52
}
virtual_ipaddress {
192.168.1.53/24 dev eth0
}
track_script {
chk_pihole
}
}
keepalived config — pihole-b (BACKUP)
/etc/keepalived/keepalived.conf on pihole-b — same file, three changes (router_id, state, priority, peer IPs):
global_defs {
router_id pihole-b
script_user root
enable_script_security
}
vrrp_script chk_pihole {
script "/usr/local/sbin/check_pihole.sh"
interval 5
timeout 3
rise 2
fall 2
}
vrrp_instance VI_PIHOLE {
state BACKUP
interface eth0
virtual_router_id 53
priority 90
advert_int 1
authentication {
auth_type PASS
auth_pass changeme-shared-secret
}
unicast_src_ip 192.168.1.52
unicast_peer {
192.168.1.51
}
virtual_ipaddress {
192.168.1.53/24 dev eth0
}
track_script {
chk_pihole
}
}
A few things to note:
virtual_router_id 53is arbitrary but must match on both nodes (and not collide with any other VRRP group on your LAN).- The
auth_passis not meaningful security — VRRP is unauthenticated for practical purposes — but matching it is required. - I prefer unicast VRRP (
unicast_src_ip+unicast_peer) over the default multicast on home networks; multicast across consumer switches and APs is hit-and-miss. - We use a tracking script so that “is keepalived running” doesn’t equal “DNS is working” — if FTL crashes but the Pi is still up, MASTER should yield.
The track script
/usr/local/sbin/check_pihole.sh on both nodes:
#!/usr/bin/env bash
# Exit 0 = healthy, non-zero = unhealthy (yield VIP)
set -u
# 1. FTL must be running
systemctl is-active --quiet pihole-FTL || exit 1
# 2. DNS must actually answer
dig +tries=1 +timeout=2 @127.0.0.1 pi.hole +short >/dev/null || exit 2
exit 0
Make it executable and start the service:
sudo chmod 750 /usr/local/sbin/check_pihole.sh
sudo systemctl enable --now keepalived
sudo systemctl status keepalived
Verifying VIP ownership
On pihole-a (the MASTER):
ip -4 addr show eth0
# Expect to see both 192.168.1.51/24 AND 192.168.1.53/24
sudo journalctl -u keepalived -n 50 --no-pager
# Expect: "(VI_PIHOLE) Entering MASTER STATE"
On pihole-b (the BACKUP):
ip -4 addr show eth0
# Expect to see ONLY 192.168.1.52/24 (no .53)
sudo journalctl -u keepalived -n 50 --no-pager
# Expect: "(VI_PIHOLE) Entering BACKUP STATE"
From any other machine on the LAN:
dig @192.168.1.53 example.com +short
ping -c 2 192.168.1.53
arp -an | grep 192.168.1.53
# Note the MAC — that's the current MASTER's eth0 MAC.
Force a failover and watch it move
On pihole-a:
sudo systemctl stop pihole-FTL
# Tracking script returns non-zero, MASTER yields the VIP.
Within ~5 seconds, on pihole-b:
ip -4 addr show eth0
# Now shows 192.168.1.53/24 too.
sudo journalctl -u keepalived -n 20 --no-pager
# "(VI_PIHOLE) Entering MASTER STATE"
Bring the primary back:
# on pihole-a
sudo systemctl start pihole-FTL
Because pihole-a has a higher priority (100 > 90) and we did not set nopreempt, it will preempt and reclaim the VIP. If you don’t want that — if you’d rather avoid a second failover on recovery — add nopreempt to the BACKUP’s vrrp_instance block and set its state to BACKUP regardless.
Stage 5 — Replicating Pi-hole config with Orbital Sync
keepalived solves “DNS is up”. It does not solve “both Pi-holes have the same blocklists, allowlists, and local DNS records”. For that we use Orbital Sync — the actively-maintained successor to the now-archived Gravity Sync, written specifically for Pi-hole v6.
Orbital Sync runs as a single container on one of the Pis (or anywhere, really — it just needs HTTPS access to both Pi-hole admin endpoints). It pulls the Teleporter backup from the primary on a schedule and restores it onto the secondary. Simple, idempotent, and no SSH key gymnastics.
Install Docker on pihole-a:
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
newgrp docker # or log out/in
docker --version
Create a working directory and a compose file:
sudo mkdir -p /opt/orbital-sync
sudo nano /opt/orbital-sync/compose.yaml
/opt/orbital-sync/compose.yaml:
services:
orbital-sync:
image: mattwebbio/orbital-sync:latest
container_name: orbital-sync
restart: unless-stopped
environment:
# Primary — source of truth
PRIMARY_HOST_BASE_URL: "http://192.168.1.51"
PRIMARY_HOST_PASSWORD: "${PRIMARY_PASSWORD}"
# Secondary — destination
SECONDARY_HOSTS_1_BASE_URL: "http://192.168.1.52"
SECONDARY_HOSTS_1_PASSWORD: "${SECONDARY_PASSWORD}"
# Run every 30 minutes
INTERVAL_MINUTES: "30"
# What to copy. Defaults are sane; override only if needed.
SYNC_V6_CONFIG_DNS: "true"
SYNC_V6_CONFIG_DHCP: "false" # set true ONLY if you serve DHCP from Pi-hole
SYNC_V6_GRAVITY: "true"
SYNC_V6_GRAVITY_GROUP: "true"
SYNC_V6_GRAVITY_AD_LIST: "true"
SYNC_V6_GRAVITY_AD_LIST_BY_GROUP: "true"
SYNC_V6_GRAVITY_DOMAIN_LIST: "true"
SYNC_V6_GRAVITY_DOMAIN_LIST_BY_GROUP: "true"
SYNC_V6_GRAVITY_CLIENT: "true"
SYNC_V6_GRAVITY_CLIENT_BY_GROUP: "true"
NOTIFY_ON_SUCCESS: "false"
NOTIFY_ON_FAILURE: "true"
VERBOSE: "true"
Drop the passwords into a .env next to it (don’t commit this anywhere):
sudo nano /opt/orbital-sync/.env
PRIMARY_PASSWORD=replace-with-pihole-a-admin-password
SECONDARY_PASSWORD=replace-with-pihole-b-admin-password
sudo chmod 600 /opt/orbital-sync/.env
cd /opt/orbital-sync
docker compose up -d
docker compose logs -f
You should see something like:
[orbital-sync] Signing in to http://192.168.1.51...
[orbital-sync] Backing up from primary...
[orbital-sync] Uploading backup to http://192.168.1.52...
[orbital-sync] Sync complete. Sleeping for 30 minutes.
Verifying the sync end-to-end
On pihole-a, add a custom local DNS record via the admin UI: Settings → Local DNS Records → Add (or just edit a custom list). Wait for the next sync cycle (or restart the container to force one):
docker compose restart orbital-sync
docker compose logs --tail=50 orbital-sync
Then query both:
dig @192.168.1.51 mything.lan +short
dig @192.168.1.52 mything.lan +short
# Both should return the same answer.
Stage 6 — Pointing clients at the VIP
The actual goal of all this is “clients use 192.168.1.53”. How you push that depends on your DHCP server.
If your router does DHCP
In your router’s admin UI, set the DNS servers handed out via DHCP to:
Primary DNS: 192.168.1.53
Secondary DNS: (leave blank, or set to 192.168.1.53 again)
Important: do not set the secondary to your ISP’s resolver or 1.1.1.1. If you do, clients will happily use it when convenient and bypass your blocking entirely. The whole point of the VIP is that a single DNS entry is now resilient.
If Pi-hole serves DHCP
You almost certainly do not want to enable DHCP on both Pi-holes — that ends in tears. If you want Pi-hole to do DHCP, run it on only one node, leave it disabled on the other, and accept that DHCP is not part of the HA story (clients keep their leases through a brief outage anyway). Set the DHCP-advertised gateway and DNS to the VIP 192.168.1.53.
Force a renew on a client to test
# Linux
sudo dhclient -r && sudo dhclient
resolvectl status | grep -A2 "Current DNS Server"
# macOS
sudo ipconfig set en0 DHCP
# Windows
ipconfig /release && ipconfig /renew
ipconfig /all | findstr "DNS Servers"
Stage 7 — Health-check show commands you’ll actually use
Putting the useful stuff in one place, post-build:
# Who currently owns the VIP?
ip -4 -br addr | grep 192.168.1.53
# VRRP state on this node
sudo journalctl -u keepalived -n 20 --no-pager
sudo systemctl status keepalived --no-pager
# Pi-hole health
pihole status
sudo systemctl status pihole-FTL --no-pager
pihole -c # press q to quit
# Are queries hitting BOTH boxes? (run this on each Pi)
sudo tail -f /var/log/pihole/pihole.log
# Last sync run
docker compose -f /opt/orbital-sync/compose.yaml logs --tail=30 orbital-sync
# End-to-end client test (from a laptop)
dig @192.168.1.53 example.com +short
dig @192.168.1.53 doubleclick.net +short
nslookup pi.hole 192.168.1.53
A short failover smoke test I run after any maintenance:
# From a client, in one terminal:
while true; do dig @192.168.1.53 example.com +short; sleep 1; done
# On pihole-a, in another:
sudo systemctl stop keepalived
# Watch the client loop — at most one or two queries should blip
# before pihole-b takes the VIP.
sudo systemctl start keepalived
# pihole-a reclaims (priority 100 > 90), again with at most a one-second blip.
Stage 8 — Maintenance
Updating Pi-hole. Do them one at a time. Drain a node by stopping pihole-FTL first (the track script will yield the VIP), update, restart, verify, then move to the other:
# on the BACKUP first
sudo systemctl stop pihole-FTL # belt and braces; not strictly required
pihole -up
sudo systemctl start pihole-FTL
pihole status
# then on the MASTER — VIP will move to the now-updated BACKUP for ~30s
sudo systemctl stop pihole-FTL
pihole -up
sudo systemctl start pihole-FTL
Updating the OS. sudo apt update && sudo apt full-upgrade -y followed by sudo reboot on the BACKUP first; verify the VIP didn’t move (it shouldn’t have — BACKUP rebooting is a non-event); then the MASTER (VIP moves to BACKUP, returns when MASTER comes back).
Rotating Orbital Sync passwords. Edit /opt/orbital-sync/.env, then docker compose up -d to recreate with the new env. The sync container is stateless — no data to migrate.
Backing up. gravity.db and pihole.toml are the things that matter:
sudo tar czf "pihole-$(hostname)-$(date +%F).tgz" \
/etc/pihole/pihole.toml \
/etc/pihole/gravity.db \
/etc/pihole/custom.list 2>/dev/null
Stash that on the other Pi or — better — somewhere off-box.
Wrap-up
Two Pis, one virtual IP, one synced configuration, and a fairly modest config footprint:
- Pi-hole installs itself with one curl-bash and a handful of prompts.
keepalivedgives you sub-second IP failover with a small VRRP config and a tiny health-check script.- Orbital Sync replaces the old Gravity Sync story for Pi-hole v6 and keeps both nodes identical without SSH glue.
The real test of any HA setup is whether you’d be willing to reboot the primary at 7pm on a Sunday in front of the family. With the failover smoke test above, you should be able to.
If you want to go further from here: add IPv6 (Pi-hole speaks it natively, you’ll just want a second vrrp_instance for VI_PIHOLE_V6), point a Prometheus exporter at FTL for graphs, or layer DNS-over-HTTPS upstream with a local Unbound for a fully-recursive resolver. All optional — none of it changes the core architecture above.