Skip to content
twinkling.topServicesPi-hole中文
boardPi 4B 2GB and up
kernel6.6.51+rpt-rpi-v8
idle temp48 °C
archarm64
reference host: Pi 4B 8GB, Bookworm 64-bit

Deployment recipe · DNS & filtering

Pi-hole on a Raspberry Pi: blocking DNS without taking the house offline

Pi-hole is the first container that goes on a new rack and the one that causes the most trouble, because every device in the house depends on it. This is the configuration that has run here since 2024: port 53 on the LAN, the admin UI on 8081, upstream pointed at a local Unbound rather than a public resolver, and a container restart policy that does not fight the network stack at boot.

pihole/pihole:2026.05.2 · 2026-08-14

Pi-hole rack plate: board stack and host ports
service parameters
imagepihole/pihole:2026.05.2
host ports:53/tcp+udp, :8081/tcp
volume path/srv/homelab/pihole/etc-pihole
RAM240 MB
CPU share0.35 vCPU (cpus: "0.35")
update cadencequarterly, pinned tag; read the release notes first — v6 moved every setting into FTLCONF_* env names
arm64arm64 native image; the 32-bit tag still exists but the FTL binary is faster on arm64
port map and exposure
hostcontainerprotoexposedused for
:5353tcp+udpLAN onlyDNS for the whole house, served by dnsmasq inside the container
:808180tcpLAN onlyadmin web UI and the /admin API the other services query

Deployment steps

  1. Prepare the host directories and the secret

    Create the volume path before the first start. If Docker creates it for you it lands as root, and FTL then cannot write its own database after an update.

    run
    sudo mkdir -p /srv/homelab/pihole/etc-pihole
    sudo chown -R 1000:1000 /srv/homelab/pihole
    echo "PIHOLE_PASSWORD=$(openssl rand -base64 18)" >> /srv/homelab/.env
    chmod 600 /srv/homelab/.env
  2. Create the shared rack network once

    Fixed IPs on a named bridge network are what let the upstream address stay stable across restarts. Allocate the subnet once and reuse it for every container on the rack.

    run
    docker network create rack --subnet 172.20.0.0/24 --gateway 172.20.0.1
    docker network inspect rack -f '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
  3. Bring up Unbound first, then Pi-hole

    Pi-hole's upstream has to be listening before the first query arrives, otherwise the container log fills with SERVFAIL for the first minute after every reboot.

    run
    cd /srv/homelab/unbound && docker compose up -d
    docker exec unbound drill @127.0.0.1 -p 5335 example.com >/dev/null && echo "resolver ok"
    cd /srv/homelab/pihole && docker compose up -d
  4. Verify from a client, not from the host

    The host resolves through its own stub resolver, so a test on the Pi tells you almost nothing. Point one laptop at the Pi and check the answer time and the blocking behaviour there.

    run
    dig @192.168.10.20 ads.doubleclick.net +short
    dig @192.168.10.20 github.com +short
    # blocked name returns 0.0.0.0, real name returns an address in under 20 ms
  5. Move the DHCP client list over before you forget

    Pi-hole can answer DHCP too, and once it does the query log shows hostnames instead of bare IP addresses. That is the difference between a log you can read and a wall of numbers.

    run
    docker exec pihole pihole-FTL --config dhcp.active true
    docker exec pihole pihole-FTL --config dhcp.start 192.168.10.100
    docker exec pihole pihole-FTL --config dhcp.end 192.168.10.240

Why port 53 is the whole job

Pi-hole is a DNS server in front of a blocklist. Everything else about it — the dashboard, the query log, the client groups — is reporting on that one function. So the only two decisions that matter are where it answers and who it asks.

It answers on the LAN only. The container publishes 53 on the Pi's LAN address, nothing is forwarded at the router, and FTLCONF_dns_listeningMode is set explicitly rather than left to the default, because a resolver that answers anyone is an open relay.

It asks a local Unbound instance on the container network at 172.20.0.3, not 8.8.8.8. That keeps the query log local, removes the per-look-up latency of a round trip to a public resolver, and means the house keeps resolving when the WAN drops.

Setting the port bands up front

Pick the ports before the second container exists, not after. This rack uses 53xx for DNS and filtering, 80xx for web front ends, and keeps upstream defaults where an application has a famous one. Pi-hole keeps 53 for DNS, which is unavoidable, and takes 8081 for the UI so 80 stays free for the reverse proxy.

bandused fortaken on this rack
53 / 5335DNSPi-hole 53, Unbound 5335
80xxweb front ends8081 Pi-hole, 8082 Nextcloud, 8096 Jellyfin
90xxmetrics9090 Prometheus, 9091 Authelia
upstream defaultsapps with a famous port3000 Grafana, 3001 Uptime Kuma, 2283 Immich

The v6 environment-variable change

Pi-hole v6 replaced the old setupVars.conf and the WEBPASSWORD environment variable with FTLCONF_* names read by FTL itself. Copying a compose file written for v5 gives you a container that starts, serves DNS, and silently ignores the password you set — the UI then asks you to set one on first visit and forgets it after the next recreate.

The three that matter here are FTLCONF_webserver_api_password, FTLCONF_dns_upstreams and FTLCONF_dns_listeningMode. Anything still named WEBPASSWORD in your file is a v5 leftover.

shell
docker exec pihole pihole-FTL --config webserver.api.password
# v6: prints the hash FTL is actually using. An empty value means your env var was ignored.

Ordering against the network at boot

The failure people hit is not a crash, it is a race. The container starts before the Pi has a LAN address, dnsmasq binds to nothing, and every device in the house gets a resolution failure until you restart the container by hand.

restart: unless-stopped covers the restart itself, but it does not cover the ordering. What fixes it properly is a systemd drop-in on the host that waits for the network to be online before Docker starts, which is the same change that keeps every other container from fighting for an address at boot.

shell
sudo systemctl edit docker.service
# [Unit]
# After=network-online.target
# Wants=network-online.target

sudo systemctl daemon-reload && sudo systemctl restart docker

Client groups do more than the blocklists

The default group blocks ads for everything on the network, which is roughly right and occasionally wrong: a smart TV that breaks when telemetry is blocked, a work laptop that needs a domain the list considers an ad server.

Two groups solve it here. A 'kids' group with the stricter lists applied to the tablets, and a 'no-block' group with nothing enabled, applied to exactly two MAC addresses. Adding a device to the second group takes ten seconds and saves a support call.

compose file

Drop the whole file at /srv/homelab/pihole/compose.yaml. Tags are pinned, never latest: rolling back on a Pi is far more work than upgrading.

compose.yaml
services:
  pihole:
    image: pihole/pihole:2026.05.2
    container_name: pihole
    hostname: pihole
    restart: unless-stopped
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "8081:80/tcp"
    environment:
      TZ: Asia/Shanghai
      FTLCONF_webserver_api_password: ${PIHOLE_PASSWORD}
      FTLCONF_dns_upstreams: "172.20.0.3#5335"
      FTLCONF_dns_listeningMode: "all"
      FTLCONF_dns_domain: "lan"
    volumes:
      - /srv/homelab/pihole/etc-pihole:/etc/pihole
    cap_add:
      - NET_ADMIN
      - SYS_NICE
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }
    networks:
      rack:
        ipv4_address: 172.20.0.2

networks:
  rack:
    name: rack
    ipam:
      config:
        - subnet: 172.20.0.0/24

Hardening checklist

  • the web UI is reachable only from the LAN VLAN; port 8081 is never forwarded at the router
  • FTLCONF_webserver_api_password comes from /srv/homelab/.env (mode 600), not from the compose file
  • DNS is bound to the LAN interface only, so the Pi does not answer recursive queries arriving from the internet
  • admin API access from the reverse proxy is limited to the two services that need it, with the Pi-hole password in a separate Caddy snippet

Backup plan

the whole /etc/pihole directory nightly at 03:10 to the SMB share on the NAS with rclone, 14 daily snapshots kept, plus a copy of gravity.db after every adlist rebuild. If the Pi dies, restoring the directory onto a fresh card brings back blocklists, local DNS records and client groups without re-running the installer.

Verify it went in clean

  • dig against the Pi returns the blocked 0.0.0.0 answer for an ad hostname and a real address for a normal one
  • the container log is free of 'no upstream servers' lines after a reboot from cold
  • docker stats shows the container under 300 MB after 24 hours, which is when dnsmasq's cache has settled
  • the admin UI loads over the LAN and refuses from a phone on the guest network

What bit us

  • An SD card that has been writing query logs for a year dies without warning. Move /etc/pihole onto a USB SSD, or at minimum set max-file: "3" on the json-file log driver so FTL's own logs cannot fill the card.
  • Setting a Pi-hole password through the web UI on a container that also receives FTLCONF_webserver_api_password means the next recreate silently reverts to the env value. Keep one source of truth: the .env file.
  • Blocking the telemetry domains of a smart TV can make it lose its pairing every time it reboots. That is what the no-block group is for.
  • The 2026.05 image runs as user 1000. A volume directory owned by root makes the container start and then fail on the first gravity run with a permission error in the log.

Hardware questions

How much RAM does Pi-hole really need on a 2 GB board?
Around 250 MB resident with the default lists and a 10,000-entry cache. The figure people quote of 100 MB comes from a v5 container on a quiet network. On a 2 GB Pi 4 that leaves plenty of room, but if you also run Immich or Jellyfin on the same board, budget 240 MB for Pi-hole in your total.
Can I run Pi-hole and Unbound in one container?
You can, and some images do. Keeping them apart is worth the extra 90 MB: when a blocklist rebuild goes wrong you restart Pi-hole without dropping the resolver, and you can point a second Pi-hole at the same Unbound if you ever add a redundancy node.
Pi 5 or Pi 4 for a DNS box?
Pi 4B, and ideally the 2 GB one. DNS is a latency problem, not a throughput problem — a Pi 4 answers a cached query in about 1 ms, and the Pi 5's faster cores change nothing you can measure from a browser. Spend the difference on the SSD.