How to Set Up Pi-hole in Docker Compose and Keep It Running
Step-by-step guide to setting up Pi-hole in Docker Compose — working compose file, port 53 conflicts on Ubuntu, network modes, env vars, and volumes.
If you’ve landed here, you already know what Pi-hole does. Here’s how to set up Pi-hole in Docker Compose — including the port 53 conflict that bites most people on first run, the environment variables that actually matter in v6, and how to make the thing survive container updates without losing your blocklists.
Who This Is For (and Who Should Skip It)
Good fit:
- You’re comfortable with
docker compose up -dand bind mounts - You want Pi-hole as a network-wide DNS filter, pointed at by your router
- You’re on Ubuntu 20.04+ or any Debian-based distro with
systemd-resolvedrunning
Skip this guide if: you want Pi-hole to serve DHCP as well. DHCP needs the container to send Layer 2 broadcasts, which bridge networking won’t pass through. For DHCP you’ll need macvlan or network_mode: host — covered briefly later, but not the focus here. Both modes are explained in our Docker Compose networking guide.
For pure DNS filtering, bridge mode is the right call.
The Port 53 Problem (Sort This First)
On Ubuntu 20.04 and later, systemd-resolved occupies port 53 on 127.0.0.53. Pi-hole can’t bind there and the container will start but DNS won’t work. Check before you do anything:
sudo ss -tulpn | grep ':53'
If you see systemd-resolve in the output, free the port:
sudo systemctl stop systemd-resolved
sudo systemctl disable systemd-resolved
sudo rm /etc/resolv.conf
echo "nameserver 1.1.1.1" | sudo tee /etc/resolv.conf
That last step gives the host a working resolver until Pi-hole is up. Once Pi-hole is running you can change /etc/resolv.conf to point at 127.0.0.1 if you want the host to use it too.
The Compose File
This is the canonical starting point, pulled from the official Pi-hole Docker documentation:
services:
pihole:
container_name: pihole
image: pihole/pihole:latest
ports:
- "53:53/tcp"
- "53:53/udp"
- "80:80/tcp"
- "443:443/tcp"
environment:
TZ: "America/New_York"
FTLCONF_webserver_api_password: "changeme"
FTLCONF_dns_listeningMode: "ALL"
volumes:
- ./etc-pihole:/etc/pihole
cap_add:
- NET_ADMIN
restart: unless-stopped
A few things worth flagging:
FTLCONF_dns_listeningMode: 'ALL' is required on Docker’s default bridge network. Without it, Pi-hole only listens on its loopback interface and silently drops DNS queries coming in from the rest of your LAN. This is the most common cause of “Pi-hole started but nothing is being filtered.”
cap_add: NET_ADMIN is needed for DHCP and some IPv6 operations. Safe to include even if you’re not enabling DHCP — the overhead is negligible.
restart: unless-stopped rather than always. If Pi-hole crashes because port 53 is still occupied, always will restart it in a tight loop. unless-stopped halts after repeated failures and lets you investigate.
Environment Variables That Matter
Pi-hole v6 replaced most configuration knobs with an FTLCONF_* naming scheme. The full variable reference lives at docs.pi-hole.net. The ones you’ll actually touch:
| Variable | Purpose |
|---|---|
TZ | Timezone for log rotation. Use a real TZ string: America/Chicago, Europe/Berlin, etc. |
FTLCONF_webserver_api_password | Web UI password. Omit it and Pi-hole logs a randomly generated one on first start. |
FTLCONF_dns_listeningMode | Set to ALL on bridge networks. Leave it alone if using host or macvlan. |
FTLCONF_dns_upstreams | Upstream resolver(s), semicolon-separated. Defaults to Cloudflare and Google if unset. |
FTLCONF_dns_dnssec | Set to true to enable DNSSEC validation upstream. |
To use a specific upstream resolver:
FTLCONF_dns_upstreams: "1.1.1.1;8.8.8.8"
Or a local Unbound instance running on the same host:
FTLCONF_dns_upstreams: "127.0.0.1#5335"
The #port suffix tells Pi-hole’s FTL to query on a non-standard port, which is how you chain Pi-hole with Unbound for recursive DNS without leaking queries to a third party.
Volumes and What They Actually Persist
The one volume mount that matters:
./etc-pihole:/etc/pihole
This persists Pi-hole’s gravity database (gravity.db), custom DNS entries, allowlists, and the main configuration file. Without it, every container update wipes your blocklists, allowlist entries, and local DNS records — which is a miserable way to discover you hadn’t persisted anything.
The ./etc-dnsmasq.d:/etc/dnsmasq.d mount you’ll see in older compose examples is only needed if you’re migrating from Pi-hole v5 or maintaining custom dnsmasq configs. Fresh v6 installs don’t need it.
Networking: Bridge vs Macvlan
Bridge (default): The compose file above uses this. Pi-hole gets a container IP, and you point your router’s DHCP “DNS server” field at the host machine’s LAN IP. Every device on the network routes DNS through Pi-hole without any per-device configuration. This is the setup that works for 90% of homelabs.
Macvlan: Pi-hole gets its own IP address on the LAN subnet, appearing as a separate network device. Useful when you want Pi-hole to serve DHCP (it needs to send broadcasts), or when your router’s firmware won’t let you set a custom DNS IP. The tradeoff: the Docker host cannot directly reach the macvlan container. You need a macvlan shim interface (ip link add macvlan0 link eth0 type macvlan mode bridge) on the host to communicate with it. More setup, more to debug — the full network definition, the persistent shim unit, and the address-range rules are in the Docker macvlan setup guide.
Host mode (network_mode: host) works too and sidesteps the port 53 dance, but it removes all network isolation — the container shares the host’s entire network stack. Reasonable for a dedicated Pi-hole box; less ideal on a host running other containers, since port conflicts then become host-wide. The Docker Compose host networking guide covers what else that line switches off, including the published ports it silently ignores.
A dedicated Pi-hole box is a genuinely reasonable answer here, and it does not need much: the budget tier in the mini PC guide for a Docker homelab covers boxes that idle under 10W, which is the relevant number for something that runs permanently.
Starting Pi-hole and Verifying It Works
docker compose up -d
docker compose logs -f pihole
Wait for a line containing Pi-hole FTL is listening in the logs, then open http://<host-ip>/admin. Log in with your configured password.
First run is slower — Pi-hole downloads and builds its initial gravity database (blocklists). Progress shows in the web UI under “Gravity” or in the container logs.
To test DNS resolution from the host:
dig @127.0.0.1 ads.google.com
A blocked domain should return 0.0.0.0 or NXDOMAIN. An unblocked domain should return the real IP.
Updating Without Data Loss
docker compose pull
docker compose up -d --remove-orphans
Pi-hole’s built-in cron (baked into the image) refreshes blocklists weekly automatically, so you don’t need to run gravity updates manually. The pull + up -d cycle above handles image updates; your data persists because you’re using named volumes. If you would rather click that than type it, Portainer does the same job from a browser.
If you want upgrade predictability in a setup where DNS downtime matters, pin to a specific version tag from the pihole/pihole Docker Hub page instead of latest, then bump the tag deliberately when you’re ready.
DNS Filtering as a Security Layer
Pi-hole isn’t just about skipping ads. DNS-layer filtering is one of the cheapest security controls you can run at home — blocking malware C2 domains, phishing infrastructure, and telemetry endpoints before a connection even starts. The next cheapest is putting everything else behind one proxy; start with the Nginx Proxy Manager tutorial.
Related across the network
- OctoPrint Raspberry Pi Setup: Hardware and First Boot — octoprintlab.com
- Pi-hole Hardware Requirements and What to Buy — piholehq.com
Sources
Related
Docker Compose Networking: Bridge, DNS, Ports & Aliases
Learn Docker Compose networking from default and external networks to service-name DNS, aliases, port publishing, host mode, macvlan, and common fixes.
Docker Compose Host Networking (network_mode: host)
How network_mode host works in Docker Compose, why published ports stop working, what it breaks for DNS and Traefik, and when to pick it anyway.
Docker Macvlan in Compose: Give Containers a LAN IP
Set up a Docker macvlan network in Compose so containers get real LAN IPs, plus the host-to-container gotcha and the shim interface that fixes it.