560 lines
16 KiB
Markdown
560 lines
16 KiB
Markdown
# Pi-hole Deployment Plan
|
|
|
|
**Status:** Historical Pi-hole design. It is no longer the active DNS source of truth. Both the PD and NOMAD Pi-hole runtimes were retired on 2026-05-27 after cutover to the Technitium trio, and the old VIP `10.5.30.53` no longer answers. Treat this document as history/reference, not the current intended DNS architecture.
|
|
**Architecture:** 3-node HA cluster with Keepalived VIP, Unbound recursive DNS, Nebula Sync
|
|
|
|
> **Warning:** The examples below describe the original HA Pi-hole plan. The entire Pi-hole path has now been retired. Current live DNS is Technitium on `10.5.30.8`, `10.5.30.9`, and `10.5.30.10`; the old Pi-hole VIP `10.5.30.53` is dead. Do not treat anything below as the desired current-state DNS design.
|
|
|
|
---
|
|
|
|
## Architecture Overview
|
|
|
|
```
|
|
[All LAN clients]
|
|
↓ DNS: 10.5.30.53 (Virtual IP — floats to whichever node is MASTER)
|
|
↓
|
|
┌─────┴──────────────────────────────────┐
|
|
│ Keepalived VIP │
|
|
│ 10.5.30.53/24 │
|
|
└──────┬──────────────┬──────────────────┘
|
|
│ │ │
|
|
PD (MASTER) N.O.M.A.D. (BACKUP1) RPi4 (BACKUP2)
|
|
10.5.30.6 10.5.30.7 10.5.1.X
|
|
priority 100 priority 90 priority 80
|
|
Pi-hole :53 Pi-hole :53 Pi-hole :53
|
|
Unbound :5335 Unbound :5335 Unbound :5335
|
|
│ │ │
|
|
└──────────────┴──────────────────┘
|
|
↓
|
|
[Root DNS servers]
|
|
(recursive — no third-party upstream)
|
|
```
|
|
|
|
- **Keepalived** floats the VIP `10.5.30.53` to whichever node is alive with the highest priority
|
|
- **Unbound** on each node acts as the local recursive resolver (replaces Cloudflare/Quad9)
|
|
- **Pi-hole** on each node forwards upstream queries to its local Unbound (`127.0.0.1#5335`)
|
|
- **Nebula Sync** (running on PD) syncs all Pi-hole configs from primary → replicas daily
|
|
- Current live replica target is NOMAD at `10.5.30.7:8954`
|
|
|
|
All nodes use `network_mode: host` for Pi-hole, Unbound, and Keepalived — this is required for Keepalived to manipulate the VIP and for Pi-hole to bind cleanly to port 53.
|
|
|
|
---
|
|
|
|
## Pre-Deploy: Network Planning
|
|
|
|
1. **Pick a free static IP for the VIP** — `10.5.30.53` is recommended (memorable for DNS). Reserve it in UniFi so DHCP never assigns it.
|
|
2. **Find the LAN interface name** on each node:
|
|
```bash
|
|
# PD / RPi4:
|
|
ip link show
|
|
# Look for the interface with 10.5.1.x address — likely eth0, eno1, enp3s0, etc.
|
|
```
|
|
3. **Note RPi4 IP** — fill in `10.5.1.X` throughout this doc once known. Reserve it as static in UniFi.
|
|
4. **Verify port 53 is free on PD:**
|
|
```bash
|
|
sudo ss -tulnp | grep ':53'
|
|
# Should show only 127.0.0.1:53 (TrueNAS loopback resolver).
|
|
# If it shows 0.0.0.0:53, see Gotchas section.
|
|
```
|
|
|
|
---
|
|
|
|
## Unbound Config (shared across all nodes)
|
|
|
|
Each node runs Unbound as a Docker container on the host network, listening on `127.0.0.1:5335`.
|
|
|
|
**Save this as `unbound.conf` in each node's appdata path** (see per-node sections below):
|
|
|
|
```ini
|
|
server:
|
|
verbosity: 1
|
|
interface: 127.0.0.1
|
|
port: 5335
|
|
do-ip4: yes
|
|
do-udp: yes
|
|
do-tcp: yes
|
|
do-ip6: no
|
|
|
|
# DNSSEC
|
|
auto-trust-anchor-file: "/opt/unbound/etc/unbound/var/root.key"
|
|
val-permissive-mode: no
|
|
|
|
# Performance
|
|
num-threads: 2
|
|
cache-min-ttl: 300
|
|
cache-max-ttl: 86400
|
|
prefetch: yes
|
|
prefetch-key: yes
|
|
minimal-responses: yes
|
|
|
|
# Privacy
|
|
qname-minimisation: yes
|
|
hide-identity: yes
|
|
hide-version: yes
|
|
|
|
# Access control
|
|
access-control: 127.0.0.1/32 allow
|
|
access-control: 0.0.0.0/0 refuse
|
|
|
|
# Root hints — download updated copy periodically
|
|
root-hints: "/opt/unbound/etc/unbound/var/root.hints"
|
|
```
|
|
|
|
**Download root hints** (run once on each node, into the appdata path):
|
|
```bash
|
|
curl -o /path/to/appdata/unbound/var/root.hints https://www.internic.net/domain/named.cache
|
|
mkdir -p /path/to/appdata/unbound/var
|
|
# Unbound will auto-manage root.key for DNSSEC
|
|
```
|
|
|
|
---
|
|
|
|
## Node 1: PlausibleDeniability (MASTER)
|
|
|
|
**Repo stack dir:** `/home/fizzlepoof/repos/truenas-stacks/pihole/`
|
|
**Live stack dir:** `/mnt/docker-ssd/docker/compose/pihole/`
|
|
**Web UI port:** `8953`
|
|
|
|
### Pre-Deploy
|
|
|
|
```bash
|
|
sudo mkdir -p /mnt/tank/docker/appdata/pihole-primary/etc-pihole
|
|
sudo mkdir -p /mnt/tank/docker/appdata/unbound-primary/var
|
|
sudo mkdir -p /mnt/docker-ssd/docker/compose/pihole
|
|
# Download root hints
|
|
sudo curl -o /mnt/tank/docker/appdata/unbound-primary/var/root.hints https://www.internic.net/domain/named.cache
|
|
```
|
|
|
|
### docker-compose.yaml
|
|
|
|
```yaml
|
|
services:
|
|
unbound:
|
|
image: mvance/unbound:latest
|
|
container_name: unbound
|
|
network_mode: host
|
|
restart: unless-stopped
|
|
volumes:
|
|
- /mnt/tank/docker/appdata/unbound-primary:/opt/unbound/etc/unbound/
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "drill @127.0.0.1 -p 5335 cloudflare.com || exit 1"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
|
|
pihole:
|
|
image: pihole/pihole:latest
|
|
container_name: pihole
|
|
network_mode: host
|
|
restart: unless-stopped
|
|
depends_on:
|
|
unbound:
|
|
condition: service_healthy
|
|
environment:
|
|
TZ: ${TZ}
|
|
FTLCONF_webserver_api_password: ${PIHOLE_PASSWORD}
|
|
FTLCONF_dns_listeningMode: "LOCAL"
|
|
FTLCONF_dns_upstreams: "127.0.0.1#5335"
|
|
FTLCONF_webserver_port: "8953"
|
|
volumes:
|
|
- /mnt/tank/docker/appdata/pihole-primary/etc-pihole:/etc/pihole
|
|
cap_add:
|
|
- SYS_NICE
|
|
|
|
keepalived:
|
|
image: osixia/keepalived:2.0.20
|
|
container_name: keepalived
|
|
network_mode: host
|
|
restart: unless-stopped
|
|
cap_add:
|
|
- NET_ADMIN
|
|
- NET_BROADCAST
|
|
- NET_RAW
|
|
volumes:
|
|
- ./keepalived.conf:/container/environment/01-custom/keepalived.conf
|
|
environment:
|
|
KEEPALIVED_VIRTUAL_IPS: "#PYTHON2BASH:['10.5.30.53/24']"
|
|
```
|
|
|
|
### keepalived.conf (PD — MASTER)
|
|
|
|
```
|
|
global_defs {
|
|
router_id pihole_pd
|
|
}
|
|
|
|
vrrp_instance VI_DNS {
|
|
state MASTER
|
|
interface ${LAN_INTERFACE}
|
|
virtual_router_id 53
|
|
priority 100
|
|
advert_int 1
|
|
nopreempt
|
|
|
|
authentication {
|
|
auth_type PASS
|
|
auth_pass ${KEEPALIVED_PASSWORD}
|
|
}
|
|
|
|
virtual_ipaddress {
|
|
10.5.30.53/24
|
|
}
|
|
}
|
|
```
|
|
> Replace `${LAN_INTERFACE}` with your actual interface (e.g. `eno1`). Replace `${KEEPALIVED_PASSWORD}` with a shared secret used on all three nodes.
|
|
|
|
### .env.example
|
|
|
|
```env
|
|
TZ=America/New_York
|
|
PIHOLE_PASSWORD=CHANGE_ME
|
|
KEEPALIVED_PASSWORD=CHANGE_ME
|
|
LAN_INTERFACE=eno1
|
|
```
|
|
|
|
### Deploy
|
|
|
|
Preferred PD-first flow now that the repo-side stack exists:
|
|
|
|
```bash
|
|
cd /mnt/docker-ssd/docker/compose/pihole
|
|
cp .env.example .env
|
|
nano .env
|
|
/usr/bin/bash bin/prepare_pd.sh
|
|
/usr/bin/bash bin/prepare_pd.sh --up
|
|
sudo docker logs pihole-primary --tail 20
|
|
sudo docker logs unbound-pihole-primary --tail 20
|
|
```
|
|
|
|
---
|
|
|
|
## Node 2: N.O.M.A.D. (BACKUP1)
|
|
|
|
N.O.M.A.D. runs Ubuntu bare metal. Deploy via Docker Compose on the host.
|
|
|
|
**Live service root:** `/opt/pihole-nomad`
|
|
**Service-local data root:** `/opt/pihole-nomad/data/`
|
|
**Web UI port:** `8954`
|
|
|
|
### docker-compose.yaml
|
|
|
|
Same structure as PD but with different ports/paths and priority:
|
|
|
|
```yaml
|
|
services:
|
|
unbound:
|
|
image: mvance/unbound:latest
|
|
container_name: unbound-pihole
|
|
network_mode: host
|
|
restart: unless-stopped
|
|
volumes:
|
|
- /opt/pihole-nomad/data/unbound:/opt/unbound/etc/unbound/
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "drill @127.0.0.1 -p 5335 cloudflare.com || exit 1"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
|
|
pihole:
|
|
image: pihole/pihole:latest
|
|
container_name: pihole-nomad
|
|
network_mode: host
|
|
restart: unless-stopped
|
|
depends_on:
|
|
unbound-pihole:
|
|
condition: service_healthy
|
|
environment:
|
|
TZ: ${TZ}
|
|
FTLCONF_webserver_api_password: ${PIHOLE_PASSWORD}
|
|
FTLCONF_dns_interface: "enp5s0"
|
|
FTLCONF_dns_listeningMode: "SINGLE"
|
|
FTLCONF_dns_upstreams: "127.0.0.1#5335"
|
|
FTLCONF_webserver_port: "8954"
|
|
FTLCONF_ntp_ipv4_active: "false"
|
|
FTLCONF_ntp_ipv6_active: "false"
|
|
FTLCONF_ntp_sync_active: "false"
|
|
volumes:
|
|
- /opt/pihole-nomad/data/pihole/etc-pihole:/etc/pihole
|
|
cap_add:
|
|
- SYS_NICE
|
|
|
|
keepalived:
|
|
image: osixia/keepalived:2.0.20
|
|
container_name: keepalived-pihole
|
|
network_mode: host
|
|
restart: unless-stopped
|
|
cap_add:
|
|
- NET_ADMIN
|
|
- NET_BROADCAST
|
|
- NET_RAW
|
|
volumes:
|
|
- ./keepalived.conf:/container/environment/01-custom/keepalived.conf
|
|
```
|
|
|
|
### NOMAD host prerequisite
|
|
|
|
If `systemd-resolved` is holding a local DNS stub on port 53, disable it before relying on NOMAD Pi-hole for LAN DNS:
|
|
|
|
```bash
|
|
sudo mkdir -p /etc/systemd/resolved.conf.d
|
|
cat <<'EOF' | sudo tee /etc/systemd/resolved.conf.d/99-pihole.conf
|
|
[Resolve]
|
|
DNSStubListener=no
|
|
EOF
|
|
sudo systemctl restart systemd-resolved
|
|
sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf
|
|
```
|
|
|
|
### keepalived.conf (N.O.M.A.D. — BACKUP1)
|
|
|
|
```
|
|
global_defs {
|
|
router_id pihole_nomad
|
|
}
|
|
|
|
vrrp_instance VI_DNS {
|
|
state BACKUP
|
|
interface enp5s0
|
|
virtual_router_id 53
|
|
priority 90
|
|
advert_int 1
|
|
|
|
authentication {
|
|
auth_type PASS
|
|
auth_pass SAME_PASSWORD_AS_PRIMARY
|
|
}
|
|
|
|
virtual_ipaddress {
|
|
10.5.30.53/24
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Node 3: Raspberry Pi 4 (BACKUP2)
|
|
|
|
### Install Docker (if not already installed)
|
|
|
|
```bash
|
|
curl -fsSL https://get.docker.com | sh
|
|
sudo usermod -aG docker $USER
|
|
```
|
|
|
|
### Pre-Deploy
|
|
|
|
```bash
|
|
mkdir -p ~/pihole/etc-pihole
|
|
mkdir -p ~/pihole/unbound/var
|
|
curl -o ~/pihole/unbound/var/root.hints https://www.internic.net/domain/named.cache
|
|
sudo apt install keepalived -y
|
|
```
|
|
|
|
### docker-compose.yaml
|
|
|
|
```yaml
|
|
services:
|
|
unbound:
|
|
image: mvance/unbound:latest
|
|
container_name: unbound
|
|
network_mode: host
|
|
restart: unless-stopped
|
|
volumes:
|
|
- ~/pihole/unbound:/opt/unbound/etc/unbound/
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "drill @127.0.0.1 -p 5335 cloudflare.com || exit 1"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
|
|
pihole:
|
|
image: pihole/pihole:latest
|
|
container_name: pihole
|
|
network_mode: host
|
|
restart: unless-stopped
|
|
depends_on:
|
|
unbound:
|
|
condition: service_healthy
|
|
environment:
|
|
TZ: ${TZ}
|
|
FTLCONF_webserver_api_password: ${PIHOLE_PASSWORD}
|
|
FTLCONF_dns_listeningMode: "LOCAL"
|
|
FTLCONF_dns_upstreams: "127.0.0.1#5335"
|
|
FTLCONF_webserver_port: "8955"
|
|
volumes:
|
|
- ~/pihole/etc-pihole:/etc/pihole
|
|
cap_add:
|
|
- SYS_NICE
|
|
```
|
|
|
|
### keepalived (native on RPi4)
|
|
|
|
```bash
|
|
sudo nano /etc/keepalived/keepalived.conf
|
|
```
|
|
|
|
```
|
|
global_defs {
|
|
router_id pihole_rpi4
|
|
}
|
|
|
|
vrrp_instance VI_DNS {
|
|
state BACKUP
|
|
interface eth0
|
|
virtual_router_id 53
|
|
priority 80
|
|
advert_int 1
|
|
|
|
authentication {
|
|
auth_type PASS
|
|
auth_pass SAME_PASSWORD_AS_PRIMARY
|
|
}
|
|
|
|
virtual_ipaddress {
|
|
10.5.30.53/24
|
|
}
|
|
}
|
|
```
|
|
|
|
```bash
|
|
sudo systemctl enable keepalived
|
|
sudo systemctl start keepalived
|
|
```
|
|
|
|
---
|
|
|
|
## Nebula Sync (on PD)
|
|
|
|
[Nebula Sync](https://github.com/lovelaze/nebula-sync) syncs Pi-hole v6 configuration (gravity, blocklists, allowlists, custom DNS) from the primary to both replicas.
|
|
|
|
**Add to PD's pihole docker-compose.yaml:**
|
|
|
|
```yaml
|
|
nebula-sync:
|
|
image: lovelaze/nebula-sync:latest
|
|
container_name: nebula-sync
|
|
restart: unless-stopped
|
|
environment:
|
|
PRIMARY: "http://127.0.0.1:8953|${PIHOLE_PASSWORD}"
|
|
REPLICAS: "http://10.5.30.7:8954|${PIHOLE_PASSWORD_NOMAD},http://10.5.1.X:8955|${PIHOLE_PASSWORD_RPI}"
|
|
RUN_GRAVITY: "true"
|
|
FULL_SYNC: "false"
|
|
CRON: "0 3 * * *"
|
|
networks:
|
|
- default
|
|
```
|
|
|
|
> - `PRIMARY` — your PD Pi-hole (note: uses `127.0.0.1` since nebula-sync is on the same host; port 8953 is the web UI, also the API port in Pi-hole v6)
|
|
> - `REPLICAS` — comma-separated list of secondary instances
|
|
> - `CRON` — syncs daily at 3 AM; force a manual sync with `docker exec nebula-sync nebula-sync run`
|
|
> - Each Pi-hole instance can (and should) have a **different** password — nebula-sync handles auth per-instance
|
|
> - Add `PIHOLE_PASSWORD_NOMAD` and `PIHOLE_PASSWORD_RPI` to your `.env` file
|
|
|
|
### What Nebula Sync copies
|
|
- Gravity database (blocklists, allowlists, group assignments)
|
|
- Custom DNS/CNAME records
|
|
- Client groups
|
|
- Does **not** sync per-instance settings (web password, interface binding — these stay local)
|
|
|
|
---
|
|
|
|
## Router Configuration (UniFi)
|
|
|
|
Point **only the VIP** as DNS:
|
|
|
|
1. UniFi Network → Settings → Networks → **LAN** → DHCP Name Server: **Manual**
|
|
2. **DNS Server 1:** `10.5.30.53` (VIP — floats between nodes)
|
|
3. **DNS Server 2:** *(leave blank or set a public fallback like `1.1.1.1` only if you want internet DNS as last resort)*
|
|
4. Save and renew DHCP leases
|
|
|
|
> **Why single VIP instead of listing all three IPs?**
|
|
> Clients round-robin across all listed DNS servers — so some queries bypass Pi-hole entirely. With Keepalived the VIP always points to exactly one live Pi-hole node, ensuring 100% of queries are filtered.
|
|
|
|
---
|
|
|
|
## Current live endpoints
|
|
|
|
- **PD admin UI:** `http://10.5.30.6:8953/admin/`
|
|
- **NOMAD admin UI:** `http://10.5.30.7:8954/admin/`
|
|
- **VIP for DNS clients:** `10.5.30.53`
|
|
|
|
## Post-Deploy
|
|
|
|
### Verify VIP is working
|
|
|
|
```bash
|
|
# Check which node holds the VIP
|
|
ip addr show | grep 10.5.30.53 # Run on each node — only MASTER shows it
|
|
|
|
# Test DNS through VIP
|
|
nslookup google.com 10.5.30.53
|
|
nslookup doubleclick.net 10.5.30.53 # Should return 0.0.0.0 (blocked)
|
|
```
|
|
|
|
### Test failover
|
|
|
|
```bash
|
|
# On PD, bring down keepalived
|
|
sudo docker stop keepalived
|
|
# Wait ~3 seconds, then check from another machine:
|
|
nslookup google.com 10.5.30.53 # Should still work — N.O.M.A.D. took over VIP
|
|
# Restore
|
|
sudo docker start keepalived
|
|
```
|
|
|
|
### Add blocklists (on primary — Nebula Sync propagates to replicas)
|
|
|
|
| List | URL |
|
|
|------|-----|
|
|
| Steven Black (unified) | `https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts` |
|
|
| OISD Basic | `https://basic.oisd.nl/` |
|
|
| Hagezi Pro | `https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt` |
|
|
|
|
After adding: **Tools → Update Gravity** on PD, then run Nebula Sync to push to replicas.
|
|
|
|
### Local DNS records
|
|
|
|
Add in Pi-hole (primary) → Local DNS → DNS Records:
|
|
|
|
| Domain | IP |
|
|
|--------|----|
|
|
| pd.lan | 10.5.30.6 |
|
|
| nomad.lan | 10.5.30.7 |
|
|
| rocinante.lan | 10.5.30.112 |
|
|
| rpi4.lan | 10.5.1.X |
|
|
|
|
Nebula Sync will propagate these to all replicas.
|
|
|
|
### Pangolin for admin UIs (optional)
|
|
|
|
| Subdomain | Target |
|
|
|-----------|--------|
|
|
| `pihole.paccoco.com` | `http://10.5.30.6:8953` (primary) |
|
|
| `pihole2.paccoco.com` | `http://10.5.30.7:8954` (N.O.M.A.D.) |
|
|
|
|
Use `/admin/` on the public hostname for the Pi-hole admin UI path.
|
|
|
|
Enable Pangolin auth — Pi-hole admin should not be publicly accessible.
|
|
|
|
---
|
|
|
|
## Storage Decisions
|
|
|
|
| Node | Path | Tier | Reason |
|
|
|------|------|------|--------|
|
|
| PD | `/mnt/tank/docker/appdata/pihole-primary` | tank | Low write frequency |
|
|
| PD | `/mnt/tank/docker/appdata/unbound-primary` | tank | Config + root hints only |
|
|
| N.O.M.A.D. | `/opt/pihole-nomad/data` | Local service root | Self-contained NOMAD service data |
|
|
| RPi4 | `~/pihole/` | SD card / SSD | Dedicated Pi, no other concerns |
|
|
|
|
---
|
|
|
|
## Known Gotchas
|
|
|
|
- **Port 53 conflict on PD:** TrueNAS binds to `127.0.0.1:53`. With `network_mode: host`, Pi-hole will try to bind to all interfaces. Set `FTLCONF_dns_listeningMode: LOCAL` so it only listens on the actual LAN interface, not loopback.
|
|
- **keepalived interface name:** Must match the actual NIC name on each host. Get it with `ip link show`.
|
|
- **`virtual_router_id` must be unique** per VRRP group on your LAN. `53` is a good mnemonic. Confirm no other keepalived instance on your LAN uses the same ID.
|
|
- **Pi-hole v6 API port:** In v6, the web UI and API both run on the `FTLCONF_webserver_port`. Nebula Sync and the web browser use the same port.
|
|
- **nopreempt on MASTER:** The `nopreempt` option in PD's keepalived.conf prevents PD from immediately reclaiming the VIP when it comes back up after a failure. Remove it if you want PD to always retake MASTER on recovery.
|
|
- **Unbound cold start:** Unbound takes a few seconds to start and validate DNSSEC. The `depends_on: condition: service_healthy` in the Pi-hole compose ensures Pi-hole waits for Unbound before starting.
|