The Gap Between Demo and Production

The official pi-hole/docker-pi-hole image ships with a clean quick-start docker-compose.yml that gets DNS ad-blocking running in under 60 seconds. But the moment you move beyond localhost testing—exposing it to a home network or integrating it into an existing service mesh—you hit three operational boundaries the quick-start doesn't address:

  1. The container runs as root by default, giving any compromised Pi-hole process full host filesystem access if combined with a container escape.
  2. Healthchecks rely on curl hitting the web interface, which doesn't validate actual DNS resolution—your stack reports healthy while DNS queries silently fail.
  3. Upstream DNS configuration persists in a SQLite database inside the container, making declarative infrastructure-as-code impossible without custom entrypoint surgery.

Non-Root Hardening: The UID/GID Trap

Pi-hole's lighttpd and pihole-FTL processes bind to privileged ports 53 (DNS) and 80 (HTTP) by default. Dropping to a non-root user requires three coordinated changes:

# Custom Dockerfile extending official image FROM pihole/pihole:latest RUN groupadd -g 999 pihole && \  useradd -r -u 999 -g pihole pihole && \  chown -R pihole:pihole /etc/pihole /var/www/html USER pihole EXPOSE 8053/tcp 8053/udp 8080/tcp 

But this breaks immediately: pihole-FTL refuses to start because it can't write to /var/log/pihole-FTL.log or create shared-memory segments in /dev/shm. The fix requires mounting specific paths with correct ownership:

services: pihole: build: ./pihole-hardened volumes: - ./pihole-data:/etc/pihole:rw - /dev/shm:/dev/shm:rw tmpfs: - /tmp:uid=999,gid=999 cap_drop: - ALL cap_add: - NET_BIND_SERVICE 

The NET_BIND_SERVICE capability lets the non-root process bind to port 53 without needing --privileged. But now you've traded root access for a new failure mode: if your Docker host's /dev/shm has noexec set in /etc/fstab, pihole-FTL crashes with a cryptic "cannot allocate memory" error. The root cause? FTL expects to mmap executable shared memory.

Healthcheck Theatre vs Real DNS Validation

The stock compose file includes:

healthcheck: test: ["CMD", "curl", "-f", "http://localhost/admin"] 

This passes as long as the web interface responds—even when DNS is completely broken. A production-grade check validates the actual DNS resolution path:

healthcheck: test: ["CMD", "dig", "+short", "@127.0.0.1", "pi.hole", "A"] interval: 30s timeout: 5s retries: 3 

But this introduces a new dependency: the image doesn't ship with dig. You either add bind-tools in a custom layer (bloating the image by 8MB) or switch to nslookup, which has inconsistent exit codes across Alpine and Debian base images.

The SQLite Configuration Drift Problem

Pi-hole stores upstream DNS servers in /etc/pihole/gravity.db. Changing them via environment variables only works on first boot—subsequent container restarts ignore PIHOLE_DNS_ vars because the DB takes precedence. The official solution is "use the web UI or CLI," which breaks declarative deploys.

The workaround requires an init script that forcibly resets the DB on every start:

#!/bin/bash sqlite3 /etc/pihole/gravity.db \ "UPDATE ftl SET value='1.1.1.1;8.8.8.8' WHERE key='DNS_SERVERS'" exec /s6-init 

But now you've coupled your infrastructure to Pi-hole's internal schema, which has broken twice in the last 18 months during minor version upgrades.

The Resource Limit Dilemma

Pi-hole's FTL process uses a ring buffer in shared memory for query logs. Setting Docker memory limits below the buffer size causes silent truncation—queries succeed, but the dashboard shows no history. The default buffer is 256MB, but under burst traffic (e.g., a browser opening 40 tabs), memory spikes to 512MB for ~30 seconds.

Setting mem_limit: 512m works until you run on a host with memory pressure, where the OOM killer terminates Pi-hole mid-query, poisoning downstream DNS caches with SERVFAIL responses that persist for 5 minutes.

Where Does This Leave You?

The pi-hole/docker-pi-hole image is production-capable, but the gap between the quick-start and a hardened deployment isn't documented. You'll either:

  • Accept running as root and rely on network isolation
  • Invest 2-3 days hardening the image and tracking schema changes
  • Use Pi-hole's Helm chart, which handles most of this but locks you to Kubernetes

What's your deployment's actual blast radius if Pi-hole gets compromised—and is the ad-blocking ROI worth the operational surface area?