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 5335 and 8953
  • 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 dig inside 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.bak

Directory 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 -d

Set Pi-hole admin password (required on first start)#

docker exec -it pihole-test pihole setpassword

WEBPASSWORD only applies during first initialization.


Configure Pi-hole upstream DNS (required)#

Open:

http://<raspberry-pi-ip>:8080/admin

In Settings → DNS:

  • Disable all default upstream resolvers
  • Set custom upstream DNS to:
    unbound
    (port 53 is implicit)

Test DNS via Docker Pi-hole#

dig google.com @127.0.0.1 -p 8053
dig dnssec-failed.org @127.0.0.1 -p 8053

Expected:

  • google.com resolves
  • dnssec-failed.org returns SERVFAIL

How to Prove Docker Pi-hole Uses Docker Unbound#

Run from the host:

docker exec -it pihole-test dig google.com @unbound

Example:

SERVER: 172.18.x.x#53(unbound)

Why this is definitive:

  • 172.18.x.x is a Docker-only subnet
  • Bare-metal Unbound cannot bind to this address
  • The name unbound exists only inside Docker DNS

Optional hard proof:

docker compose stop unbound
docker exec -it pihole-test dig google.com @unbound

This 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#

  1. System Settings → Network → Wi‑Fi → Details → DNS
  2. Add the Raspberry Pi IP address as the only DNS server
  3. Remove all other DNS servers
  4. 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 8053

Effect:

  • 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 8053

Enable Tier‑1 on iOS (phone)#

Step 1 — Configure DNS on iOS#

  1. Settings → Wi‑Fi → (ⓘ your network)
  2. Set Configure DNS → Manual
  3. Add the Raspberry Pi IP address
  4. 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 8053

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 <IPHONE_IP> -j REDIRECT --to-ports 8053
sudo iptables -t nat -D PREROUTING -p tcp --dport 53 -s <IPHONE_IP> -j REDIRECT --to-ports 8053

Status: Tier‑1 Canary Mode implemented; bare‑metal DNS remains stable default; Docker DNS available per‑device with safe rollback.