DHCP high availability and config sync for a Pi-hole cluster. Automatic failover, manual master override, config synchronization, and a web UI in Pi-hole's admin page — all over HTTP with no SSH keys or external dependencies. Runs on bare metal (systemd) or Docker (sidecar container).
Unofficial third-party project. pihole-ha is an independent, community-built add-on. It is not affiliated with, endorsed by, or sponsored by the Pi-hole project or Pi-hole LLC. Pi-hole® is a registered trademark of Pi-hole LLC. This project uses Pi-hole's supported interfaces (
pihole-FTL --config, the admin page) but ships no Pi-hole code and is maintained separately. Use at your own risk.
Version history: see CHANGELOG.md.
pihole-ha auto-detects how your network does DHCP and installs to match — it will not enable Pi-hole DHCP behind your back:
- DHCP-HA — Pi-hole is your DHCP server. Full failover: a standby takes over DHCP if the primary dies, with an optional floating VIP. Selected when Pi-hole DHCP is already active (or when you choose it at install).
- DNS-only — your router or another server does DHCP. pihole-ha never touches DHCP; it keeps your Pi-holes' config (blocklists, custom DNS, FTL settings) in sync and monitors peer health. If you configure a VIP, it also floats that IP to whichever node is answering DNS — so clients pointed at the VIP get real health-based DNS failover, not just two static DNS servers that stall on a dead one.
At install it checks whether Pi-hole DHCP is active, probes the LAN for another DHCP server, inherits the mode from the cluster when joining one, and asks if it's ambiguous (defaulting to the safe DNS-only). So installing on a DNS-only network won't create a second, conflicting DHCP server.
- DHCP Failover — If the primary Pi-hole goes down, a secondary node automatically takes over DHCP within ~40-80 seconds. When the primary recovers, the secondary yields back. Clients never lose DHCP.
- Virtual IP (VIP) — Optional floating IP. In DHCP-HA mode it follows the active DHCP node (clients get it as their DNS server and DHCP server-id, so renewals always reach the right node). In DNS-only mode it follows the highest-priority node that's answering DNS, giving health-based DNS failover. When disabled, clients get all node IPs as DNS servers instead.
- Config Sync — The sync primary bundles gravity DB, DHCP static leases, custom DNS records, and FTL settings — and rebuilds that bundle only when something actually changes. Standbys check the primary every ~15 minutes and download + apply only when the config differs (and even then, FTL only restarts on real changes) — plus an on-demand Sync Now button. So nothing transfers or restarts on unchanged data, and no SSH keys are needed.
- Manual DHCP Master Override — Force any node to be the DHCP server with one click. The others yield automatically. If the designated master goes down, remaining nodes fall back to priority order.
- HA Kill-Switch — Disable all DHCP failover, health checks, and VIP management cluster-wide with one toggle. Propagates to all nodes automatically.
- Pushover Notifications — Optional alerts on failover events (activation, deactivation, sync).
- API Authentication — Mutating API endpoints are protected using Pi-hole's own password. If the local Pi-hole instance has a password set, all write operations require a valid session ID (SID). Read-only endpoints remain open.
- Web UI — A native panel integrated into Pi-hole's admin interface at
/admin/ha(Tools > HA Cluster). - Cluster Join/Leave — Nodes register themselves with the cluster on install via the
/api/nodes/joinAPI. The Cluster Members panel on the HA page adds, removes, and reorders nodes with one click, and every change propagates to all peers. Adding works from either side: a standalone node joins an existing cluster rather than absorbing it, and an address that doesn't answer is refused instead of being written to the config. A removed node keeps serving DNS on its own with failover switched off, so it can be added back at any time. No manual config editing needed. - Docker Support — Runs as a sidecar container alongside the stock
pihole/piholeimage. No custom Pi-hole fork needed. Interactive installer detects existing Pi-hole containers and preserves volume mounts. - Mixed Clusters — Supports nodes running on different Pi-hole web ports (e.g., bare-metal on port 80 alongside Docker on port 8081) using
IP:PORTformat in the node list.
- 2+ Pi-hole instances (Pi-hole v6+ with
pihole-FTL) - Debian/Ubuntu-based OS (uses
apt,systemctl) - Network connectivity between all nodes on port 8887 (HTTP)
- Root access for installation
- Packages (auto-installed):
socat,curl,netcat-openbsd,arping,openssl(openssl is used to sign/verify config-sync payloads)
- Docker and Docker Compose
- Stock
pihole/pihole:latestimage (Pi-hole v6+) network_mode: hoston both containers (required for DHCP broadcast and VIP)- See Docker README for full setup guide
Note: The bare-metal (systemd) path is the primary, well-tested deployment. The Docker sidecar is newer and less battle-tested — the image builds and the logic is verified, but the full two-container runtime hasn't been exercised as broadly. Report issues if you hit them.
+-----------+
| Gateway |
| (router) |
+-----+-----+
|
DHCP relay target: VIP (.123)
|
+--------------+--------------+
| | |
+-----+-----+ +----+------+ +----+------+
| PRIMARY | | SECONDARY | | TERTIARY |
| .3 (P1) | | .5 (P2) | | .55 (P3) |
| Pi-hole + | | Pi-hole + | | Pi-hole + |
| pihole-ha | | pihole-ha | | pihole-ha |
+------------+ +-----------+ +-----------+
All nodes run the same software. Priority order determines who serves DHCP when in automatic mode.
VIP enabled: The VIP (/32) is added to the active node's interface and removed when it deactivates, with gratuitous ARP to update switch/router tables. Clients receive only the VIP as their DNS server (dhcp-option=6) and DHCP server identifier (dhcp-option=54), so lease renewals always reach the active node.
VIP disabled: No floating IP is managed. Clients receive all node IPs as DNS servers (dhcp-option=6), providing redundancy without a VIP. Clients use whichever node answered their DHCP request as the server identifier.
Run on each node (primary first, then secondaries):
git clone https://github.com/RamSet/pihole-ha-cluster.git pihole-ha
cd pihole-ha
sudo ./setup.shsetup.sh auto-detects whether Pi-hole is running as a Docker container or bare metal and runs the appropriate installer. If both are found, it asks which to use. You can also run install.sh (bare metal) or docker-install.sh (Docker) directly.
The repo you install from is the repo you keep updating from.
setup.shrecords the clone'soriginremote and branch, and from then onpihole-ha update— and the "update available" badge in the web UI — follow that repo, not this one. So if you install from your own fork, updates come from your fork and will not silently overwrite your changes with upstream. Install from a branch and it keeps following that branch.An
ssh://orgit@remote is recorded as itshttps://equivalent, becausepihole-ha updateclones as root and root has no reason to hold your deploy key — so the fork must be readable without credentials for unattended updates to work. It is recorded in/usr/local/share/pihole-ha/repo.conf; delete that file to fall back to upstream. Check which repo a node is following withpihole-ha version.
- Scans the subnet in parallel for existing pihole-ha nodes on port 8887
- Auto-detects role — if an existing cluster is found, this node joins as the next standby. If none are found (e.g. the other node is on a different subnet), it offers to join by IP: enter an existing node's address and it verifies + joins that node. Otherwise this node becomes PRIMARY.
- Preserves cluster priority — discovered nodes keep their existing order, the new node is appended last
- Detects deployment mode (DHCP-HA vs DNS-only) and asks about a VIP. In DHCP-HA it defaults to enabled — without a VIP, failover is only as fast as each client's own resolver timeout (seconds, and some clients never switch), and per-client metrics split across nodes; press enter to accept, or answer
noto opt out. In DNS-only it stays opt-in and requires typing "yes", since a VIP only helps once you point your own DHCP server at it. It never enables Pi-hole DHCP on a DNS-only network. - Asks whether to pin system DNS to
127.0.0.1(recommended, so the node always resolves independent of the VIP) — decline if your resolver lives elsewhere and you don't want/etc/resolv.conftouched. Recorded asPIN_DNSand preserved across updates. - Registers with the cluster — calls
/api/nodes/joinon each existing node so they immediately learn about the new node - Installs and starts services — scripts, systemd units (bare metal) or Docker compose stack
- Injects the HA page into Pi-hole's admin UI
For manual Docker setup without the installer, see Docker README.
After install, a global pihole-ha command is available from any directory (no need to be in the clone):
pihole-ha update # update this node (fetches fresh from the repo it was installed from)
pihole-ha status # version, service state, and this node's role / VIP / DHCP
pihole-ha version # installed version, the repo being followed, and whether an update exists
pihole-ha restart # restart the daemon and dashboard
pihole-ha logs [-f] # daemon + dashboard logs (add -f to follow)
pihole-ha debug # run the diagnostics collector
pihole-ha cluster-key # create the config-sync signing key and copy it to the other nodes
pihole-ha uninstall # remove pihole-ha (Pi-hole is left untouched)update pulls a fresh copy from the repo and branch this node was installed from (see the note under Installation) and runs the installer, so it works no matter where (or whether) the original clone still exists. pihole-ha version prints which repo that is. Commands that change the system re-run with sudo automatically. The classic cd <clone> && sudo ./install.sh --update still works too — and is how an existing install first picks up the pihole-ha command.
All config lives in /etc/pihole-ha/:
| File | Purpose |
|---|---|
nodes.conf |
Gateway, VIP, VIP/HA toggles, node list, DHCP range |
sync.conf |
Sync toggle, component toggles, sync primary |
master.conf |
DHCP master override (auto or a node IP) |
auth.conf |
Per-node Pi-hole API passwords (for locked instances) |
notify.conf |
Pushover notification credentials |
Example nodes.conf:
CONFIG_VERSION=1
GATEWAY=192.168.1.1
VIP=192.168.1.123
VIP_ENABLED=true
HA_ENABLED=true
DHCP_HA=true
PIN_DNS=true
SYNC_BLOB_DIR=/var/lib/pihole-ha
HA_NODES=192.168.1.3,192.168.1.5,192.168.1.55,192.168.1.81:8081
DHCP_START=192.168.1.11
DHCP_END=192.168.1.150
Nodes in HA_NODES can optionally include a :PORT suffix for the Pi-hole web port. Nodes without a port default to 80. This allows mixed clusters where some nodes run Pi-hole on non-standard ports (e.g., Docker nodes on 8081 alongside bare-metal nodes on 80).
| Setting | Values | Default | Description |
|---|---|---|---|
CONFIG_VERSION |
integer | 1 |
Config format version (warns on mismatch, never blocks) |
VIP_ENABLED |
true/false |
true |
Enable floating VIP management |
HA_ENABLED |
true/false |
true |
Master kill-switch for all HA functions |
DHCP_HA |
true/false |
true |
true = DHCP-HA mode (manage DHCP + VIP). false = DNS-only (never touch DHCP; VIP follows the DNS-healthy primary if set) |
PIN_DNS |
true/false |
true |
Pin this host's resolver to 127.0.0.1. false = never touch system DNS |
SYNC_BLOB_DIR |
absolute path | /var/lib/pihole-ha |
Where the sync payload, manifest and staging copies are written. Point it at another volume to keep them off the SD card. Per-node and never synced — set it only on the machines that have that path; an unusable value falls back to the default and logs blob_dir_unusable |
CHECK_INTERVAL |
seconds | 10 |
How often each node health-checks its peers. Failover ≈ CHECK_INTERVAL × ACTIVATE_AFTER |
ACTIVATE_AFTER |
integer | 2 |
Consecutive failed checks before a standby takes over (the last node waits +2 to avoid a tie). Lower = faster failover, higher = ignores brief blips |
DEACTIVATE_AFTER |
integer | 3 |
Consecutive healthy checks before a standby yields back to a recovered primary |
HEALTH_TIMEOUT |
seconds | 2 |
Per-check timeout for ping / DNS:53 / API |
STANDBY_ONLY |
bool | false |
This node never takes over DHCP or the VIP, whatever the peers look like, in every mode including DNS-only. Releases both if it already holds them. Re-read every round, so it can be set or lifted without a restart — use it to bring a node up quietly, or to calm a cluster that is already fighting. The installer sets it automatically on a node that could not register with the cluster |
AUTH_TIMEOUT |
seconds | 10 |
How long to allow for a peer login. Pi-hole hashes the app password deliberately slowly, so a 1-core Pi can need well over 5s. Raise this if the log reports a login timeout |
AUTH_RETRY_SEC |
seconds | 60 |
How long to wait before retrying a peer login that failed. Stops a bad or slow login re-hashing the password every CHECK_INTERVAL |
HA_NODES |
CSV | (required) | Node IPs in priority order. Format: IP or IP:PORT |
Tuning failover speed: the defaults are conservative (secondary takes over in ~20s) to avoid failing over on a transient blip. For faster failover, lower CHECK_INTERVAL — e.g. CHECK_INTERVAL=3 gives ~6s (secondary) / ~12s (tertiary) while still requiring 2 consecutive down-checks. Going below ~2s or ACTIVATE_AFTER=1 risks flapping on brief network hiccups.
The core daemon runs on every node (systemd service or Docker supervisor). Every 10 seconds it:
- Pings the gateway — if unreachable, takes no action (prevents split-brain during LAN outages)
- Health-checks every peer with 3 checks:
- Ping (ICMP)
- DNS port 53 (TCP)
- Pi-hole API (
/api/config/dhcp/active)
- Decides whether to serve DHCP based on
should_i_serve():- Auto mode: Primary always serves. Secondaries only serve if all higher-priority nodes fail all 3 health checks.
- Manual mode: The designated master always serves. Others yield to it. If the designated master is down, remaining nodes fall back to priority order (skipping the downed master).
- Activates or deactivates DHCP with staggered thresholds to prevent flapping:
- Activation: P1/P2 need 2 consecutive checks (40s), P3 needs 4 checks (80s)
- Deactivation: All nodes need 3 consecutive checks (60s)
- Manages the VIP (if
VIP_ENABLED=true) — adds it on activation with gratuitous ARP, removes it on deactivation - Updates DHCP options — writes
/etc/dnsmasq.d/09-pihole-ha.conf:- VIP enabled:
dhcp-option=6,<VIP>+dhcp-option=54,<VIP>(single DNS + server-id) - VIP disabled:
dhcp-option=6,<node1>,<node2>,<node3>(all nodes as DNS)
- VIP enabled:
- Writes status JSON to
/run/pihole-ha/status.jsonfor the dashboard
Two scripts handle config distribution:
pihole-ha-sync (runs on sync primary, every 15 min via systemd timer or Docker supervisor):
- Computes a hash of all enabled config components (gravity DB, DHCP hosts, DNS records, FTL settings)
- If the hash changed (or
--force), builds atar.gzpayload and a JSON manifest - Stores them in
/run/pihole-ha/where the dashboard API serves them
pihole-ha-sync-pull (runs on standby nodes, every 15 min via systemd timer or Docker supervisor):
-
Fetches the manifest from the sync primary via HTTP (
:8887/api/sync/manifest); if the primary is unreachable, it falls back to any other node that has a manifest, so a standby still receives config while the primary is down -
Compares the hash — if different, downloads the payload (
:8887/api/sync/payload) -
Applies each component:
- Gravity DB: copies to
/etc/pihole/gravity.db, restartspihole-FTLonly if the md5 changed - DHCP static leases: applies via
pihole-FTL --config dhcp.hosts - Custom DNS records: applies local A/AAAA records via
pihole-FTL --config dns.hostsand local CNAME records viapihole-FTL --config dns.cnameRecords - FTL settings: applies DNS, blocking, cache, and misc settings via
pihole-FTL --config
Array-valued keys (
dns.hosts,dhcp.hosts,dns.cnameRecords,dns.revServers) are carried as their own JSON file in the payload, because FTL prints an array as[ a, b ]but only accepts["a","b"]back — a raw round-trip is rejected. An empty list is never applied, so sync can't wipe records that exist only on the standby. - Gravity DB: copies to
Sync is entirely over HTTP on port 8887. No SSH keys, no rsync, no shared filesystem.
The publisher role fails over, and recovery never loses changes. The sync publisher isn't a fixed node — it follows the same highest-priority-alive rule as the DHCP master. If the primary goes down, a surviving node auto-promotes and starts publishing, so config edits (e.g. a new DHCP reservation) made during the outage propagate to the other survivors. When a higher-priority node returns, it catches up to the newest config before it reclaims the publisher role, so it can never overwrite changes made while it was away. Freshness is tracked by a content-version that advances only on real changes (carried in the manifest, served at /api/sync/version), so a returning node always pulls the newest config regardless of which peer it checks — and a lower-version payload is refused outright. This holds across multi-node cascades: a change made on the last surviving node survives the others recovering in any order.
The master override adds a manual control layer on top of automatic failover:
master.conf |
Behavior |
|---|---|
DHCP_MASTER=auto |
Default. Priority-based failover (P1 > P2 > P3) |
DHCP_MASTER=192.168.1.5 |
Force .5 as DHCP server. Others yield to it. If .5 goes down, remaining nodes fail over by priority among themselves. |
The setting is live-reloaded every check cycle (10s) — no service restart needed. When set via the web UI, it propagates to all nodes in the cluster automatically.
The VIP is an optional floating IP address, controlled by VIP_ENABLED in nodes.conf. In DHCP-HA mode it follows DHCP mastership (and its DHCP options are written to 09-pihole-ha.conf, below). In DNS-only mode it follows the highest-priority node that's answering DNS, giving clients pointed at the VIP health-based DNS failover — no DHCP options are written; you point clients at the VIP via your own DHCP server. The UI toggle is driven by the DHCP master (DHCP-HA) or the primary node (DNS-only).
VIP_ENABLED |
DHCP options written to 09-pihole-ha.conf |
Behavior |
|---|---|---|
true |
dhcp-option=6,<VIP> + dhcp-option=54,<VIP> |
Clients see one stable DNS endpoint. DHCP renewals always target the VIP, which follows the active node. |
false |
dhcp-option=6,<node1>,<node2>,<node3> |
Clients see all node IPs as DNS. No VIP is managed. Clients renew against whichever node answered. |
dhcp-option=6= DNS servers advertised to clients (RFC 2132)dhcp-option=54= DHCP Server Identifier — tells clients which server to contact for renewals/releases
VIP can be toggled from the web UI (dashboard or /admin/ha). The toggle propagates to all nodes automatically via the /api/vip/toggle endpoint. The daemon live-reloads VIP_ENABLED every check cycle (10s) — no restart needed.
HA_ENABLED in nodes.conf is a master kill-switch that disables all HA functions cluster-wide:
- DHCP failover stops — no activation or deactivation decisions are made
- Health checks still run (so the dashboard shows peer status), but results don't trigger any actions
- VIP management is paused
- The daemon continues running and writing status, but reports "HA disabled"
Toggle from the web UI or the API (/api/ha/toggle). The toggle propagates to all nodes automatically — one click disables/enables the entire cluster. Useful for maintenance windows or debugging.
Pi-hole's admin interface runs inside Pi-hole's embedded web server (lighttpd/CivetWeb on port 80). It uses Lua templates (.lp files) that run server-side. This is great for the integrated /admin/ha page, but it has limitations:
- No cross-node API calls from Lua without shelling out to curl
- No persistent background service — Lua pages only run on request
- CORS restrictions when the browser tries to fetch status from other nodes
The pihole-ha-dash API server on port 8887 solves this:
pihole-ha-dashis a bash script usingsocatto handle HTTP requests- It serves the status JSON, sync payloads, and all API endpoints directly
- It's the transport layer for config sync (primary serves payloads, standbys pull them)
- It handles propagation of settings (sync primary promotion, DHCP master override) by curling peer nodes
The Pi-hole admin page (/admin/ha) is the only UI. Its JavaScript talks to a same-origin Lua API proxy (ha-api.lp) that curls localhost:8887 server-side, so the browser never contacts port 8887 directly.
In short: port 8887 is the cluster's internal API (node-to-node and local proxy only). Port 80 (/admin/ha) is the Pi-hole-integrated UI that proxies through it.
Security note: Port 8887 is an internal API. Reads are unauthenticated, and writes are unauthenticated too unless the local Pi-hole has an admin password set (in which case mutating calls require a valid Pi-hole session). Keep 8887 on a trusted LAN — do not port-forward it or expose it to the internet.
pihole-ha is designed for a trusted LAN. Two controls harden it beyond that baseline:
Control-plane authentication. The :8887 API's read endpoints are always open, but every write (toggles, master/priority/VIP changes, node join/leave, notify config, saving peer passwords) requires a valid Pi-hole session — whenever the local Pi-hole has an admin password set. So the simplest hardening is: give Pi-hole an admin password, and the cluster's control plane is authenticated automatically. On a password-less Pi-hole those writes are open to the LAN, so also consider firewalling :8887 to just the cluster node IPs.
Signed config sync (recommended). Standbys apply whatever config the sync payload contains (DNS records, FTL settings, gravity DB), so a rogue peer or a MITM on the plain-HTTP transfer could otherwise push malicious config. To prevent that, put the same secret in /etc/pihole-ha/cluster.key on every node:
# On the sync primary: create the key and copy it to the other nodes over ssh.
sudo pihole-ha cluster-keypihole-ha cluster-key generates the secret (mode 600, never world-readable even
briefly), prints it, lists the peers from nodes.conf, and offers to copy it to each
one over ssh — the key travels on stdin, never as a command-line argument, so it does
not appear in ps or /proc on either end. It then compares fingerprints to confirm
each peer really holds the same key. Run it on the sync primary first (see below);
it tells you if you are on the wrong node. --show reprints the existing key,
--force replaces it.
Doing it by hand instead:
# generate once, then copy the SAME file to every node (chmod 600):
sudo sh -c 'umask 077; openssl rand -hex 32 > /etc/pihole-ha/cluster.key'
sudo systemctl restart pihole-ha-sync.timer pihole-ha-sync-pull.timer # or just wait for the next cycleWhen the key is present, the primary HMAC-signs both the payload and the manifest, and standbys verify the manifest before acting on anything in it — then verify the payload against it. Signing only the payload would leave config_version, the content hash and the per-component flags forgeable while the payload signature stayed valid, which is enough to roll a cluster back to an old (genuinely signed) config, or to silently switch off one component's sync while the node still reports itself up to date. No key = legacy unsigned behavior.
Two things to know when enabling it:
- Key every node together, primary first. A keyed standby rejects an unkeyed primary's unsigned payloads, so keying a standby first stops that standby's sync until the primary has the key too.
pihole-ha cluster-keywarns you if you run it on the wrong node. - Update every node before keying a mixed-version cluster. The manifest signature is a new field. An older standby ignores it and still verifies the payload, so an upgraded primary keeps working for it — but an upgraded, keyed standby requires it, and will reject an older primary's manifests with a message saying so. Notification credentials (
PO_USER/PO_TOKEN) are no longer shipped in the payload at all, so each node keeps its own.
Access at http://<node-ip>/admin/ha (requires Pi-hole admin login). Integrated into Pi-hole's AdminLTE interface with:
- Node selector — view status from each node's perspective
- Node cards — live status (DHCP Active / Standby / Unreachable), health checks, client count, lease count
- Cluster overview cards (color-coded small-boxes)
- Health check tables per node with auth status and password input for locked instances
- DHCP Master panel with button group
- Config Sync panel with toggles and promote buttons
- Pushover Notifications panel — enable/disable, configure credentials, send test
The HA page is injected into Pi-hole's sidebar under Tools > HA Cluster. A systemd path watcher (pihole-ha-inject.path) re-injects it automatically after Pi-hole updates.
| Script | Purpose |
|---|---|
pihole-ha |
Core DHCP failover daemon |
pihole-ha-dash |
Cluster API server (port 8887) — status, sync payloads, cluster actions |
pihole-ha-sync |
Build sync payload (primary only; checks every 15 min, rebuilds only on change) |
pihole-ha-sync-pull |
Pull sync payload (standbys only; checks every 15 min, pulls only on change) |
pihole-ha-inject |
Inject HA page into Pi-hole web UI (bare metal) |
pihole-ha-debug |
Diagnostics collector — sudo pihole-ha-debug prints a redacted support bundle |
pihole-ha-cluster-key |
Creates /etc/pihole-ha/cluster.key and copies it to the peers over ssh — see Security |
pihole-ha-platform |
Platform abstraction layer (/usr/local/lib/pihole-ha/) — detects systemd vs Docker, provides unified functions for FTL restart, sync timer management, etc. |
| File | Purpose |
|---|---|
ha.lp |
Pi-hole admin page (Lua template) |
ha-api.lp |
Same-origin API proxy for the admin page |
ha.js |
Admin page JavaScript (polling, rendering, click handlers) |
| File | Purpose |
|---|---|
setup.sh |
Unified installer — auto-detects Docker or bare metal, runs the right installer |
install.sh |
Bare metal installer (systemd services, interactive) |
docker-install.sh |
Docker installer (detects Pi-hole container, generates compose stack, interactive) |
| File | Purpose |
|---|---|
Dockerfile |
Multi-stage build: copies pihole-FTL from pihole image, Alpine 3.21 sidecar |
docker-compose.yml |
Two-container stack (pihole + pihole-ha sidecar) with shared volumes |
docker-entrypoint.sh |
Process supervisor replacing systemd — manages daemon, dash, and sync loops |
pihole-ha-inject-docker.sh |
Injects HA page into Pi-hole's admin UI at container startup |
.env.example |
All configurable environment variables with defaults |
| File | Purpose |
|---|---|
status.json |
Current node status, peer health, written every 10s by pihole-ha |
sync-payload.tar.gz |
Config payload built by sync primary |
sync-manifest.json |
Payload metadata (hash, timestamp, components) |
| Unit | Type | Purpose |
|---|---|---|
pihole-ha.service |
daemon | Core failover monitor |
pihole-ha-dash.service |
daemon | Cluster API on port 8887 |
pihole-ha-sync.timer |
timer | Build sync payload every 15 min (primary) |
pihole-ha-sync-pull.timer |
timer | Pull sync payload every 15 min (standbys) |
pihole-ha-inject.path |
path | Re-inject HA page when Pi-hole sidebar changes |
| Endpoint | Method | Description |
|---|---|---|
/api/status |
GET | Node status JSON (health checks, DHCP state, peers) |
/api/version |
GET | Installed version + latest upstream version + update_available flag |
/api/dhcp/master-config |
GET | Current DHCP master override setting |
/api/dhcp/master?ip=<auto|ip> |
GET | Set DHCP master (propagates to all nodes) |
/api/sync/config |
GET | Sync configuration (enabled, components, primary) |
/api/sync/manifest |
GET | Sync payload manifest (hash, timestamp, size) |
/api/sync/payload |
GET | Download sync payload tarball |
/api/sync/promote?ip=<ip> |
GET | Set sync primary (propagates to all nodes) |
/api/sync/toggle/<key> |
GET | Toggle sync component (enabled/gravity/dhcp/dns) |
/api/sync/now |
GET | Trigger immediate sync build or pull |
/api/ha/config |
GET | HA enabled state |
/api/ha/toggle |
GET | Toggle HA kill-switch (propagates to all nodes) |
/api/vip/config |
GET | VIP settings and current DHCP master status |
/api/vip/toggle |
GET | Toggle VIP enabled/disabled (propagates to all nodes) |
/api/auth/set?ip=<ip>&password=<pw> |
GET | Save Pi-hole API password for a peer |
/api/notify/config |
GET | Pushover notification config |
/api/notify/save?enabled=...&user=...&token=...&title=... |
GET | Save Pushover config |
/api/notify/toggle |
GET | Toggle Pushover on/off |
/api/notify/test |
GET | Send test Pushover notification |
/api/auth/check |
GET | Check if Pi-hole password auth is required |
/api/config |
GET | Cluster bootstrap config (nodes, gateway, Pi-hole port) |
/api/nodes/join?node=<ip[:port]> |
GET | Add a node, or join that node's cluster if this node is standalone. Probes the target first and refuses if it doesn't answer; propagates to all peers |
/api/nodes/leave?node=<ip> |
GET | Remove a node from the cluster (propagates to all peers) |
/api/priority/set?order=<csv> |
GET | Set node priority order (propagates to all nodes) |
/api/role/demote?ip=<ip> |
GET | Demote current primary, promote target node |
Authentication: If the local Pi-hole instance has a password set, all mutating endpoints (toggle, set, save, promote, demote) require a sid query parameter with a valid Pi-hole session ID. Read-only endpoints (/api/status, /api/config, /api/auth/check, manifests, payloads) are always open. On 401, the /admin/ha panel prompts for the peer's Pi-hole password.
All scripts use structured logging with ISO 8601 timestamps, role tags, and key=value fields:
2026-02-15T10:23:45 [PRIMARY] [INFO] event=startup ip=192.168.1.3 priority=1 interval=10s
2026-02-15T10:23:55 [PRIMARY] [WARN] event=gateway_unreachable gateway=192.168.1.1
2026-02-15T10:24:05 [PRIMARY] [INFO] event=dhcp_activate reason="all higher-priority nodes down"
# Failover daemon
journalctl -u pihole-ha -f
# Cluster API
journalctl -u pihole-ha-dash -f
# Sync (primary builds)
journalctl -u pihole-ha-sync
# Sync (standbys pull)
journalctl -u pihole-ha-sync-pullStart here: run the diagnostics collector and read (or share) its output — it gathers version, config (secrets redacted), service status, cluster/peer health, Pi-hole DNS/DHCP settings, VIP state, what FTL is listening on, dig tests against localhost and the VIP, and recent logs:
sudo pihole-ha-debug # print to screen
sudo pihole-ha-debug > ha.txt # save to a file to attach when asking for helpA common failover gotcha it surfaces: if dig @127.0.0.1 resolves but dig @<VIP> does not, FTL isn't answering on the VIP — check dns.listeningMode and that the VIP is on the interface.
Cluster API not responding on port 8887
# Bare metal:
sudo systemctl status pihole-ha-dash
sudo systemctl restart pihole-ha-dash
# Docker:
docker compose logs pihole-ha
docker compose restart pihole-haDHCP not failing over
# Check health from the node's perspective:
curl -s localhost:8887/api/status | python3 -m json.tool
# Look at peers → ping/dns/api/dhcp fieldsInstaller says "No existing HA nodes found" (but the other node is up)
- Confirm both nodes are on the same subnet — auto-discovery only scans the local
/24. - From the new node, check the other is reachable:
curl http://<other-ip>:8887/api/statusshould return JSON. - If it's reachable but wasn't found, the scan may have timed out (slow hardware) — the installer will prompt to enter the node's IP directly; type it in to join. Re-run
sudo ./install.shif you already finished the install as a separate node.
Peer API checks failing (api: false)
- Check the peer's Pi-hole web port — if non-standard, add
:PORTto the node inHA_NODES - Example:
HA_NODES=192.168.1.10,192.168.1.11:8081
Sync not working
# On primary — check if payload exists:
ls -la /run/pihole-ha/sync-payload.tar.gz
# On standby — check pull logs (bare metal):
journalctl -u pihole-ha-sync-pull --since "1 hour ago"
# On standby — check pull logs (Docker):
docker compose logs pihole-ha | grep SYNC-PULL
# Manual trigger:
sudo /usr/local/bin/pihole-ha-sync --force # on primary
sudo /usr/local/bin/pihole-ha-sync-pull --force # on standbyPi-hole API shows "Locked" in health checks
- The peer has a Pi-hole admin password set
- Go to
/admin/ha, find the node's health table, enter the password in the Auth row - Password is saved to
/etc/pihole-ha/auth.confand picked up next check cycle
HA page missing from Pi-hole sidebar after update
# Bare metal:
sudo /usr/local/bin/pihole-ha-inject
sudo systemctl status pihole-ha-inject.path
# Docker: restart both containers to re-inject
docker compose restartRun the tests (no root needed, nothing outside a tmpdir is touched):
bash tests/test-ha.sh # unit + structural
bash tests/test-loop.sh # the failover loop, end to endtest-ha.sh covers IP validation, config version parsing, role detection, node reorder logic, structured log format, auth check logic, and syntax checking of all scripts.
test-loop.sh runs the real daemon loop against fake ip, ping, nc, curl and pihole-FTL binaries, describes a cluster in files, and asserts which addresses and DHCP calls the daemon actually made — takeover, yielding back, standing down, and DNS-only mode. It exists because unit tests cannot reach decisions made in the main loop, which is where two failover bugs hid while 300+ tests passed.
The other node must already have Pi-hole HA installed and be reachable on port 8887.
Open Cluster Members on any node's /admin/ha page, enter the other node's IP, and click Add Node. Add a :PORT suffix only if that node's Pi-hole web interface is not on port 80.
You can run this from either side — the node you are adding, or the node you are joining. The target is contacted first and the direction is chosen from what it reports:
| This node | The address you entered | What happens |
|---|---|---|
| In a cluster | Standalone | It is added to this cluster at the lowest priority |
| Standalone | In a cluster | This node joins that cluster at the lowest priority; the existing primary is not disturbed |
| Standalone | Standalone | The two form a new cluster, this node as PRIMARY |
| In a cluster | In a different cluster | Refused — remove it from the other cluster first |
| Any | Not answering on 8887 |
Refused, and nothing is changed |
That last row matters: an address with nothing behind it is never written to HA_NODES. Accepting one would leave this node as PRIMARY of a cluster whose only peer never answers, which is enough for it to claim the VIP and start a DHCP server.
Whichever direction it runs, the resulting membership is pushed to every node including the one being added, so all of them agree without any manual config editing.
# Add 192.168.1.55 (run from any existing member):
curl "http://localhost:8887/api/nodes/join?node=192.168.1.55"
# Or, from a standalone node, join the cluster that 192.168.1.3 belongs to:
curl "http://localhost:8887/api/nodes/join?node=192.168.1.3"
# Non-standard Pi-hole web port:
curl "http://localhost:8887/api/nodes/join?node=192.168.1.55:8081"Open Cluster Members on any node's /admin/ha page and click the red × next to the node. You can also use the Leave Cluster button on the node's own card. Either way the change propagates to all peers.
It becomes standalone rather than being torn down:
- Its node list is reset to just itself and
HA_ENABLEDis set tofalse. - It stops serving DHCP, and releases the VIP if it held it, so it can't compete with the cluster it just left.
pihole-FTLkeeps running, so the node carries on serving DNS on its own IP.- Its HA page reports the node as
STANDALONEand explains how to rejoin.
A node with no peers never runs failover logic. It has no way to tell "every peer died" from "I was removed", and assuming the former would make it seize a VIP another node still holds and start a second DHCP server on the network.
To bring it back, add it from a node that is still in the cluster (see Adding a Node).
# Remove node 192.168.1.55 from the cluster (run from any node):
curl "http://localhost:8887/api/nodes/leave?node=192.168.1.55"
# Propagates to all other nodes, and tells 192.168.1.55 to stand itself downThe HA Cluster panel shows the installed version and flags when a newer release is available (checked once a day against this repo). To update, pull the latest and refresh the installed code in place — config and cluster membership (including your PIN_DNS choice) are left untouched:
cd pihole-ha && git pull
sudo ./install.sh --updateFrom the cloned repo. This leaves the cluster (so peers drop this node), stops and disables all services, and removes the binaries, config, and admin-panel injection:
sudo ./install.sh --uninstall # add -y to skip the confirmation prompt# Leave the cluster first:
curl "http://localhost:8887/api/nodes/leave?node=$(hostname -I | awk '{print $1}')"
cd pihole-ha/docker
docker compose down