How to Run Two Pi-holes with Keepalived Failover (DNS That Survives a Server Reboot)

One DNS address, two Pi-holes: keepalived moves the IP to whichever is alive. Reboot the server; nobody notices.

Once Pi-hole does DNS (34) and DHCP (144), the server is the heart of the network — and every reboot, update or power cut (119) takes the house's internet with it until it's back. The fix is a second Pi-hole on a Raspberry Pi and keepalived: both machines share a virtual IP; whichever is healthy holds it; the router points at that address and never knows which box is answering. A config sync keeps the two identical.

Quick reference

PieceWhat
Primarythe server, Pi-hole, real IP 192.168.1.50
Secondarya Pi, Pi-hole, real IP 192.168.1.60
Virtual IP (VIP)192.168.1.53 — what the router and devices use for DNS
keepalivedon both, VRRP, priority 150 (server) / 100 (Pi), health check on port 53
Syncnebula-sync container (Pi-hole v6 API) from primary to secondary
Testsudo systemctl stop keepalived on the server; dig @192.168.1.53 still answers

1. Pi-hole on the Pi

Install Pi-hole on the Pi the same way as tutorial 34 (Docker, or the bare-metal installer — curl -sSL https://install.pi-hole.net | bash is simplest on a Pi). Give it a fixed IP 192.168.1.60, the same upstream (Unbound on the server, 83, or its own), and leave DHCP off on it for now.

2. keepalived on both

On the server:

sudo apt install keepalived -y
sudo nano /etc/keepalived/keepalived.conf

vrrp_script chk_dns {
script "/usr/bin/dig @127.0.0.1 -p 53 pi.hole +time=1 +tries=1"
interval 5
fall 2
rise 2
}

vrrp_instance DNS {
state MASTER
interface enp3s0
virtual_router_id 53
priority 150
advert_int 1
unicast_src_ip 192.168.1.50
unicast_peer {
192.168.1.60
}
authentication {
auth_type PASS
auth_pass a-short-secret
}
virtual_ipaddress {
192.168.1.53/24
}
track_script {
chk_dns
}
}

On the Pi, the same file with state BACKUP, priority 100, interface eth0, unicast_src_ip 192.168.1.60 and the peer 192.168.1.50.

sudo apt install dnsutils -y # for dig in the check
sudo systemctl enable --now keepalived
ip a | grep 192.168.1.53 # appears on the server (MASTER)

The health check asks the local Pi-hole to resolve pi.hole every five seconds; two failures and that node drops the VIP, which the other picks up within a second or two.

Pi-hole must listen on the VIP: in Pi-hole's DNS settings choose Permit all origins (or bind to all interfaces). With Docker and network_mode: host that's automatic; with a published port it's 0.0.0.0:53.

3. Point the house at the VIP

Router (or Pi-hole's own DHCP, 144) → DNS server 192.168.1.53. Devices pick it up on lease renewal. From now on queries go to whichever Pi-hole holds the VIP.

If Pi-hole is also the DHCP server, run DHCP on the primary only (the Pi keeps it off); a brief outage of DHCP is harmless because leases persist (144, step 5). Running two DHCP servers requires more care than it's worth here.

4. Keep the two Pi-holes identical

Blocklists, allow/deny lists, local DNS records and groups must match or failover changes behaviour. nebula-sync copies them from primary to replica via Pi-hole's API:

mkdir -p ~/docker/nebula-sync && cd ~/docker/nebula-sync
nano compose.yaml

services:
nebula-sync:
image: ghcr.io/lovelaze/nebula-sync:latest
container_name: nebula-sync
environment:
PRIMARY: http://192.168.1.50|${PIHOLE_PASSWORD}
REPLICAS: http://192.168.1.60|${PIHOLE_PASSWORD}
FULL_SYNC: "true"
RUN_GRAVITY: "true"
CRON: "0 */6 * * *"
TZ: Europe/London
restart: unless-stopped

(Use the Pi-hole's web port if it isn't 80.) Every six hours the replica is made identical, and gravity updates. Edit lists on the primary only.

5. Test it

dig @192.168.1.53 example.com # from a laptop: answers
sudo systemctl stop keepalived # on the server
dig @192.168.1.53 example.com # still answers — now from the Pi
ip a | grep 192.168.1.53 # on the Pi: it has the VIP
sudo systemctl start keepalived # server takes it back (higher priority)

Then the real test: reboot the server during the evening. Nobody looks up from their phone.

6. Unbound too

If the primary's upstream is Unbound on the server (83), the Pi's Pi-hole can't use it while the server is down. Give the Pi its own Unbound, or an external upstream as its second choice — Pi-hole tries upstreams in order.

Troubleshooting

Both nodes claim the VIP. They can't see each other's VRRP packets: wrong peer IPs, a firewall blocking VRRP (protocol 112) or UDP unicast — sudo ufw allow from 192.168.1.60 on each, or allow proto vrrp.

VIP moves to the Pi but DNS fails. The Pi's Pi-hole isn't listening on all interfaces (Permit all origins).

Flapping. Health check too tight on a busy server; raise interval/fall, or check Pi-hole isn't briefly unresponsive during gravity updates.

Devices still use the server's real IP. DHCP hands out .50 as DNS; change it to the VIP and let leases renew.

Wrapping up

A second Pi-hole on a Pi, keepalived on both with a health check, the router pointed at a virtual IP, nebula-sync keeping them identical. The server becomes something you can reboot at 8 pm with the family watching telly — which is what "the heart of the network" should have meant all along.

Call us