keepalived check that fails over when Pi-hole stops blocking, not just when it stops answering

If you run two Pi-holes behind a keepalived VIP, your health check probably asks "does DNS answer?" Mine did. It catches a dead Pi-hole. It doesn't catch the one that's up, answering everything, and blocking nothing.

That's what happened to me recently. A sync tool (nebula-sync with RUN_GRAVITY=true) started twice in quick succession and ran two gravity rebuilds on my primary at once. The primary came out with no gravity table and every group mapping empty. DNS worked fine. Blocking was off for the whole house, and keepalived had no reason to move the VIP to the perfectly healthy backup.

So I wrote a second track script. It asks the node for doubleclick.net and fails only if it gets a real IP while blocking is switched on. That second part matters. Pausing Pi-hole sets dns.blocking.active to false, so a pause never moves the VIP. A broken database leaves it true.

Tested on v6 (FTL 6.7.1) with keepalived 2.2.7:

  • forced failure on the primary: VIP moved in 28 s and came back 48 s after clearing, with no failed queries
  • real mapping wipe on the backup: its priority dropped, VIP stayed put
  • 5-minute pause of both nodes: nothing happened, which is the point

One trap if you write your own: don't use +norecurse for this. It's fine for a pi.hole liveness check, but an unblocked name is then answered only from cache, and the check flips between pass and fail without ever tripping.

Script, keepalived config and notes: Pi-hole + keepalived: fail over when Pi-hole stops blocking, not just when it stops answering · GitHub

Two related lessons, in case they save someone an evening of tinkering:

As-is, not a maintained project. Happy to answer questions here.

Time to upgrade : [RELEASE] Pi-hole HA — automatic DHCP failover, VIP, and config sync for a Pi-hole cluster

And mention anything you are missing to @RamSet :grimacing::+1::+1:

Thanks! I hadn't seen Pi-hole HA. I read through it, and it's a cleaner setup than keepalived plus nebula-sync. It also copies gravity.db instead of triggering gravity remotely, which sidesteps the double-rebuild that wiped my primary.

Two things would still have bitten me with it: the health check doesn't test blocking, and the sync doesn't check what's in the database before copying it. I've passed both along to RamSet in his release thread. If they land, I'd move my cluster over.

yeah dig-for-answer only misses a live pihole that stopped blocking.

i run a keepalived check that digs a known blocked domain and greps the sinkhole:

#!/bin/bash
IP=$(dig +short +time=2 +tries=1 doubleclick.net @127.0.0.1 | head -1)
[ -z "$IP" ] && exit 1
case "$IP" in
  0.0.0.0|127.0.0.1|::|::1) exit 0 ;;
  *) exit 1 ;;
esac

point track_script at that. VIP drops when blocking dies. pick a domain u know is on ur gravity (and tweak the case if ur mode returns NXDOMAIN instead).

Thanks - that's the shape I started with. The catch I hit: a deliberate pihole disable also returns a real IP, so the VIP moves on every pause, and if you pause both nodes together (I do, from Home Assistant) both fail the check and keepalived drops the VIP entirely - a DNS outage instead of a few ads. That's why mine reads dns.blocking.active first and only fails on a real answer while blocking is supposed to be on. Agreed on picking a domain you know is in gravity, and on adjusting for NXDOMAIN mode.