Pi-hole and unbound in Docker experiment#
Goal#
Create a reproducible, low-risk hybrid setup on a Raspberry Pi:
- Bare metal: PiVPN (WireGuard), production Pi-hole + Unbound
- Docker (shadow setup): Pi-hole + Unbound for testing and migration
- Safe rollback at all times (reboot or stop Docker)
- End goal: repo can be cloned onto a fresh Pi and brought up quickly
This README documents the current known-good state, the reasoning behind key decisions, and the next steps so work can be safely continued later.
Current State (Important)#
Production (unchanged)#
- Bare-metal Pi-hole running on port
53 - Bare-metal Unbound running on ports
5335and8953 - PiVPN (WireGuard) running and in use
- Home network still uses the bare-metal DNS stack
⚠️ Production services have not been modified.
Shadow / Test Setup (Docker)#
Docker Pi-hole#
- Pi-hole v6.x (currently v6.3 core)
- DNS on host port
8053(TCP/UDP) - Web UI on host port
8080 - Uses Docker-internal Unbound as its only upstream
Docker Unbound#
- Unbound 1.24.2
- True recursive resolver
- DNSSEC validation enabled
- Runs only inside Docker networking
- Listens on port 53 inside Docker
- No host port exposure
Data flow#
Client → Docker Pi-hole → Docker Unbound → Internet- No port conflicts with production
- Rollback is instant (
docker compose down)
Image Pinning (Important)#
Docker images are pinned by digest, not by tag.
Why digest pinning was chosen#
- Floating tags (
latest, version tags) were evaluated and failed in practice - This system runs alongside production DNS and VPN
- Bit-for-bit reproducibility is required
Digest pinning guarantees:
- No silent upstream changes
- Identical behavior on redeploy
- Known-good state is frozen and auditable
Example:
image: pihole/pihole@sha256:…
image: klutchell/unbound@sha256:…Upgrades are explicit and intentional.
Healthchecks#
Pi-hole healthcheck (enabled)#
Pi-hole includes a functional DNS healthcheck:
- Runs
diginside the container - Queries a well-known domain
- Confirms that Pi-hole is actually answering DNS queries
This provides a real, meaningful signal.
Unbound healthcheck (intentionally omitted)#
Docker Unbound uses the klutchell/unbound image, which is:
- Fully distroless
- No shell
- No coreutils (
pgrep,ps,kill, etc.)
As a result:
- Process-level healthchecks cannot work
- Attempted checks always report
unhealthy, even when Unbound is running
Important design decision:
Unbound has no Docker healthcheck by design.
Rationale:
- If Unbound fails, PID 1 exits and Docker restarts the container
- There is no meaningful “half-broken” state to detect
- Removing the healthcheck avoids false negatives and confusion
This is intentional and documented.
Safety Backups Taken#
sudo cp -a /etc/pihole /root/pihole.bak
sudo cp -a /etc/unbound /root/unbound.bak
sudo cp -a /etc/wireguard /root/wireguard.bak
sudo cp -a /etc/pivpn /root/pivpn.bak
sudo iptables-save > /root/iptables.bakDirectory Structure (current)#
raspi-homelab/
├── README.md
├── docker-compose.yml
├── .env
├── pihole/
└── unbound/Key Decisions (Why Things Are This Way)#
Unbound runs on port 53 in Docker#
The Docker image used is klutchell/unbound. This image is intentionally minimal and hardened and is designed to listen on port 53.
Attempts to force Unbound to use a custom port (e.g. 5335) result in Unbound running but refusing connections.
Docker Unbound must use port 53.
This does not conflict with production, because Docker Unbound is not exposed on the host.
Docker Unbound has no host port mapping#
Unbound is an internal dependency of Pi-hole only:
- It is not reachable from the host
- It cannot be confused with bare-metal Unbound
- Docker service names are the only supported access method
Key Commands Used#
Start test stack#
docker compose up -dSet Pi-hole admin password (required on first start)#
docker exec -it pihole-test pihole setpasswordWEBPASSWORD only applies during first initialization.
Configure Pi-hole upstream DNS (required)#
Open:
http://<raspberry-pi-ip>:8080/adminIn Settings → DNS:
- Disable all default upstream resolvers
- Set custom upstream DNS to:
(port 53 is implicit)unbound
Test DNS via Docker Pi-hole#
dig google.com @127.0.0.1 -p 8053
dig dnssec-failed.org @127.0.0.1 -p 8053Expected:
google.comresolvesdnssec-failed.orgreturnsSERVFAIL
How to Prove Docker Pi-hole Uses Docker Unbound#
Run from the host:
docker exec -it pihole-test dig google.com @unboundExample:
SERVER: 172.18.x.x#53(unbound)Why this is definitive:
172.18.x.xis a Docker-only subnet- Bare-metal Unbound cannot bind to this address
- The name
unboundexists only inside Docker DNS
Optional hard proof:
docker compose stop unbound
docker exec -it pihole-test dig google.com @unboundThis must fail, proving there is no fallback to bare metal.
Tier‑1 Canary Mode (Final Intended State)#
The project intentionally stops at Tier‑1 Canary Mode rather than performing a network‑wide cutover.
What Tier‑1 Canary Mode means#
- The bare‑metal Pi‑hole + Unbound stack remains the default DNS for the network
- Selected primary devices (laptop, phone, TV) are explicitly configured to use Docker Pi‑hole + Unbound
- Each device is opted in individually
- No router‑level DNS changes or packet redirection are required
Why Tier‑1 Canary Mode exists#
- It provides clean, predictable DNS for primary devices
- It avoids fragile network‑wide DNS changes
- It keeps the blast radius minimal
- Rollback is instant and per‑device
- The Docker stack remains exercised and production‑ready without replacing the stable baseline
This is the final intended state for this setup.
How Tier‑1 Canary Mode actually works (important)#
DNS clients cannot select a port. When a device is configured to use a DNS server, it will always send queries to port 53 on that IP address.
Because of this:
- Simply pointing a laptop or phone to the Raspberry Pi IP will always reach the bare‑metal Pi‑hole (which listens on port 53)
- The device has no way to choose Docker Pi‑hole on port 8053 by itself
Tier‑1 Canary Mode therefore requires one additional mechanism on the Raspberry Pi:
Source‑IP‑scoped port redirection (iptables)
This redirect applies only to selected devices and forwards their DNS traffic from port 53 to Docker Pi‑hole on port 8053.
The bare‑metal Pi‑hole remains the default for all other devices.
DHCP reservations (required for Tier‑1)#
Tier‑1 Canary Mode depends on source‑IP‑scoped iptables rules, so Tier‑1 devices must keep a stable IP address.
The recommended approach is a DHCP reservation on the router:
- Bind each Tier‑1 device’s MAC address to a fixed IP
- Devices still use DHCP normally
- IP addresses remain stable across reboots and Wi‑Fi reconnects
This approach works reliably for laptops, phones, and TVs and requires no per‑device network configuration.
Static IP configuration on the device itself is possible, but not recommended for mobile devices.
Enable Tier‑1 on macOS (laptop)#
Step 1 — Configure DNS on macOS#
- System Settings → Network → Wi‑Fi → Details → DNS
- Add the Raspberry Pi IP address as the only DNS server
- Remove all other DNS servers
- Apply changes
macOS will now send DNS queries to <pi‑ip>:53.
Step 2 — Redirect this device to Docker Pi‑hole (required)#
On the Raspberry Pi, add source‑IP‑scoped redirects:
sudo iptables -t nat -A PREROUTING -p udp --dport 53 -s <MACBOOK_IP> -j REDIRECT --to-ports 8053
sudo iptables -t nat -A PREROUTING -p tcp --dport 53 -s <MACBOOK_IP> -j REDIRECT --to-ports 8053Effect:
- DNS from this Mac → Docker Pi‑hole
- DNS from all other devices → bare‑metal Pi‑hole
Notes:
- Disable iCloud Private Relay for the home network
- Disable Limit IP Address Tracking for this Wi‑Fi network
Rollback:
sudo iptables -t nat -D PREROUTING -p udp --dport 53 -s <MACBOOK_IP> -j REDIRECT --to-ports 8053
sudo iptables -t nat -D PREROUTING -p tcp --dport 53 -s <MACBOOK_IP> -j REDIRECT --to-ports 8053Enable Tier‑1 on iOS (phone)#
Step 1 — Configure DNS on iOS#
- Settings → Wi‑Fi → (ⓘ your network)
- Set Configure DNS → Manual
- Add the Raspberry Pi IP address
- Remove all other DNS entries
The iPhone will now send DNS queries to <pi‑ip>:53.
Step 2 — Redirect this device to Docker Pi‑hole (required)#
On the Raspberry Pi:
sudo iptables -t nat -A PREROUTING -p udp --dport 53 -s <IPHONE_IP> -j REDIRECT --to-ports 8053
sudo iptables -t nat -A PREROUTING -p tcp --dport 53 -s <IPHONE_IP> -j REDIRECT --to-ports 8053Notes:
- Disable iCloud Private Relay for the home network
- Disable Limit IP Address Tracking for this Wi‑Fi network
Rollback:
sudo iptables -t nat -D PREROUTING -p udp --dport 53 -s <IPHONE_IP> -j REDIRECT --to-ports 8053
sudo iptables -t nat -D PREROUTING -p tcp --dport 53 -s <IPHONE_IP> -j REDIRECT --to-ports 8053Status: Tier‑1 Canary Mode implemented; bare‑metal DNS remains stable default; Docker DNS available per‑device with safe rollback.