DEV Community

Cover image for Production-Hardening Pi-hole in Docker: Beyond the Quick-Start Compose
Hannah Schmidt
Hannah Schmidt

Posted on

Production-Hardening Pi-hole in Docker: Beyond the Quick-Start Compose

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?

Top comments (1)

Collapse
 
sikamikanikobg profile image
Arsen Apostolov •

curl to /admin passing while dns is dead is the classic green-dashboard trap — the check stays happy through the exact hour it matters. i hit a version of this on my own box with a different service: container reports up, the protocol underneath is down, nothing pages until a person notices. your dig check is the right fix, and the noexec /dev/shm crash is a good reminder that "works on my host" is doing a lot of heavy lifting here. if this is the only dns on the lan, the 5-minute servfail poisoning is the real cost, not the ad-blocking — i'd keep a static upstream in /etc/resolv.conf on the router as a fallback so a pihole death doesn't take the whole house offline.