Build a Highly Available Pi-hole Cluster with Ansible (VRRP)

Step-by-step guide to prepare two Linux hosts, then use Ansible to deploy a highly available Pi-hole pair with keepalived (VRRP) and a Virtual IP, plus config sync and validation - powered by my open-source playbook: ansible-pihole-cluster
Download & flash Rocky Linux 10 for Raspberry Pi#
1) Get the Raspberry Pi image#
- Go to the official Rocky Linux Download page: pick ARM (aarch64).
- Scroll to the Raspberry Pi Images section and download the image for your Pi.
2) Flash the image to a microSD card#
You can use balenaEtcher (what I use below), or Raspberry Pi Imager—both work.
Option A — balenaEtcher#
- Install/open balenaEtcher.
- Flash from file → pick the Rocky Linux RPi image.
- Select target → choose your microSD card.
- Flash! → wait for completion.

Option B — Raspberry Pi Imager#
- Open Raspberry Pi Imager.
- Click Choose OS → Use custom and select the Rocky Linux RPi image.
- Choose your microSD card and Next.
- When asked “Would you like to apply OS customisation settings?” click No (we’ll configure users/SSH/hostname later).
- You’ll get a Warning that all data on the card will be erased — click Yes.

Repeat this flashing process for both microSD cards (one per Raspberry Pi). Next, we’ll boot each Pi and continue with user/SSH hardening and networking.
Create an admin user, install SSH keys, disable password logins, remove the default user#
Note: Do this on both Raspberry Pis. Replace
danwith your preferred username.
1) Create the user and grant admin (sudo) rights#
Default credentials: User:
rocky, Password:rockylinux
# pick your username
USER=dan
# create the user and set a password (for local console; we'll disable SSH passwords next)
sudo adduser "$USER"
sudo passwd "$USER"
# add to the admin group (wheel)
sudo usermod -aG wheel "$USER"
# give passwordless sudo (NOPASSWD)
sudo su -c "echo '$USER ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/$USER"
2) Generate SSH keys on your configuration device & copy them to both Pis#
Note: Do this for both Raspberry Pis: use the matching key for each device (e.g.,
pihole-master→ primary,pihole-backup→ secondary).
- Generate keys (run on your laptop/desktop)
# On your configuration device
ssh-keygen -t ed25519 -C "pihole-master" -f ~/.ssh/pihole_master
ssh-keygen -t ed25519 -C "pihole-backup" -f ~/.ssh/pihole_backup
- Copy the public keys to each Pi
# Copy public keys (you'll be prompted for the admin user's password this one time)
ssh-copy-id -i ~/.ssh/pihole_master.pub [email protected]
ssh-copy-id -i ~/.ssh/pihole_backup.pub [email protected]
3) Harden SSH: disable root login and password-based login#
Important: Do this on both Raspberry Pis, using the new user you created.
- SSH into each Pi (replace
danwith your user and IPs with yours):
ssh [email protected] -i ~/.ssh/pihole_master
# and on the second Pi:
ssh [email protected] -i ~/.ssh/pihole_backup
- Edit the SSH daemon config:
sudo vi /etc/ssh/sshd_config
Find and set the following:
PasswordAuthentication no
PermitRootLogin no
Save and exit.
- Reload SSHD:
sudo systemctl reload sshd
- Log out and test key-only login:
# primary
ssh [email protected] -i ~/.ssh/pihole_master
# secondary
ssh [email protected] -i ~/.ssh/pihole_backup
You should be able to log in without any password prompt.
4) Remove the default rocky user (on both Pis)#
Important: Make sure you’re logged in as your new user (e.g.,
dan), notrocky.
- Remove
rockyand its home directory:
sudo userdel -r rocky || true
Note: Repeat on the second Raspberry Pi.
Expand the microSD to use all available space#
Note: We’ll grow the root partition (
/dev/mmcblk0p3) to fill the card, then expand the filesystem. Do this on both Raspberry Pis.
1) View current disk and partition layout#
Run:
sudo parted -l
If you see a prompt like this, type Fix:
Warning: Not all of the space available to /dev/mmcblk0 appears to be used, you
can fix the GPT to use all of the space (an extra 115845120 blocks) or continue
with the current setting?
Fix/Ignore? Fix
You should see something similar to:
Model: SD SD64G (sd/mmc)
Disk /dev/mmcblk0: 62.2GB
Sector size (logical/physical): 512B/512B
Partition Table: gpt
Number Start End Size File system Name Flags
1 1049kB 525MB 524MB fat16 p.UEFI boot, esp
2 525MB 1062MB 537MB linux-swap(v1) p.swap swap
3 1062MB 2914MB 1852MB ext4 p.lxroot

2) Resize the root partition with cfdisk#
From the output above, we want to expand /dev/mmcblk0p3.
sudo cfdisk /dev/mmcblk0
In the TUI that opens:
- Select the partition
/dev/mmcblk0p3(labeled Linux root (ARM-64)). - Choose Resize.

- Set it to use all remaining free space.

- Choose Write, confirm with yes (you should see “The partition table has been altered.”).


- Quit.

3) Confirm the partition was resized#
lsblk
Check that mmcblk0p3 now spans the expected size (e.g., ~57 GB on a 64 GB card).

4) Grow the filesystem to fill the partition#
sudo resize2fs /dev/mmcblk0p3
You should see output confirming the filesystem was resized successfully.

5) Reboot and verify#
sudo reboot
After the Pi comes back:
df -h /
You should now see the full capacity available on /.
Set static IP & DNS#
Note: Run these on each Pi (primary first, then secondary). If your connection name isn’t “Wired connection 1”, run
nmcli con showto find it and substitute accordingly.
Primary Pi (e.g., 10.0.20.50/24)
sudo nmcli con mod "Wired connection 1" \
ipv4.addresses 10.0.20.50/24 \
ipv4.gateway 10.0.20.1 \
ipv4.dns "1.1.1.1 1.0.0.1" \
ipv4.ignore-auto-dns yes \
ipv4.method manual
Secondary Pi (e.g., 10.0.20.51/24)
sudo nmcli con mod "Wired connection 1" \
ipv4.addresses 10.0.20.51/24 \
ipv4.gateway 10.0.20.1 \
ipv4.dns "1.1.1.1 1.0.0.1" \
ipv4.ignore-auto-dns yes \
ipv4.method manual
Verify after reboot:
nmcli dev show | grep -E 'IP4.ADDRESS|IP4.GATEWAY|IP4.DNS'
Deploy Pi-hole cluster with Ansible#
Prerequisites: Ansible installed on your configuration device and passwordless sudo enabled on both Pis (we did this earlier).
1) Clone the repository#
git clone https://github.com/danylomikula/ansible-pihole-cluster.git
cd ansible-pihole-cluster
2) Install the required collections#
ansible-galaxy collection install -r ./collections/requirements.yaml
3) Edit inventory and variables#
inventory/hosts.ini
Set your IPs, SSH key paths, and the remote user.
Important: Use the exact key filenames you created earlier (
pihole_mastervspihole-master). Adjust to match your setup.
[master]
pihole-master ansible_host=10.0.20.50 ansible_user=dan ansible_ssh_private_key_file=~/.ssh/pihole_master priority=150
[backup]
pihole-backup ansible_host=10.0.20.51 ansible_user=dan ansible_ssh_private_key_file=~/.ssh/pihole_backup priority=140
Note: Change
ansible_userand the key paths accordingly.
inventory/group_vars/all.yml
Open and set the essentials for your environment (at minimum):
# Virtual IP used by keepalived (VRRP). Point your clients/DHCP to THIS address.
pihole_vip_ipv4: "10.0.20.53/24"
# Web interface password.
pihole_webpassword: "SUPER_SECURE_PASSWORD"
# Your local search domain (e.g., "homelab.local", "lan", "home", etc.)
pihole_local_domain: "homelab.local"
4) (Optional) Quick connectivity test#
ansible all -i inventory/hosts.ini -m ping
5) Bootstrap the cluster#
ansible-playbook -i inventory/hosts.ini bootstrap-pihole.yaml
What the playbook installs (and why)
- keepalived — Provides VRRP and the floating Virtual IP so one node is always the active DNS endpoint. If the master goes down, the backup takes over automatically.
- unbound — A local validating, recursive DNS resolver. When enabled, Pi-hole forwards queries to Unbound on-box instead of public resolvers, improving privacy and reducing external dependency. Pi-hole’s official guide: https://docs.pi-hole.net/guides/dns/unbound/
- nebula-sync — A lightweight watcher/synchronizer that keeps designated Pi-hole config/state in sync between nodes (e.g., lists, local files). Project: https://github.com/lovelaze/nebula-sync
- pihole-updatelists — Automates fetching and applying block/allow lists from remote sources on a schedule, so your lists stay current without manual upkeep. Project: https://github.com/jacklul/pihole-updatelists
6) Point your network to the Virtual IP#
Update your DHCP/router (or manual client settings) to use the VIP you set in group_vars/all.yml:
- IPv4 DNS:
pihole_vip_ipv4(e.g.,10.0.20.53) - IPv6 DNS:
pihole_vip_ipv6(if configured)
7) Verify#
On whichever node should be master (higher priority), check that the VIP is present:
ip a | grep -A2 "$(yq '.pihole_interface' inventory/group_vars/all.yml)" | grep -E '10\.0\.20\.57|vip'
# or simply:
ip a show dev eth0
Confirm Pi-hole is answering:
dig @10.0.20.57 example.com +short
If that resolves, you’re done — your HA Pi-hole pair is live behind a single Virtual IP.