DockerHomeLab
How-To

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 persistent volumes.

By Dockerhomelab Editorial · · 8 min read

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 -d and 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-resolved running

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.

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:

VariablePurpose
TZTimezone for log rotation. Use a real TZ string: America/Chicago, Europe/Berlin, etc.
FTLCONF_webserver_api_passwordWeb UI password. Omit it and Pi-hole logs a randomly generated one on first start.
FTLCONF_dns_listeningModeSet to ALL on bridge networks. Leave it alone if using host or macvlan.
FTLCONF_dns_upstreamsUpstream resolver(s), semicolon-separated. Defaults to Cloudflare and Google if unset.
FTLCONF_dns_dnssecSet 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.

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.


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 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. For broader context on how DNS-layer controls fit into layered home network defenses, Techsentinel News covers network security developments worth tracking alongside this kind of setup.


Sources

Sources

  1. Pi-hole Docker Documentation
  2. Pi-hole Docker Configuration Reference
  3. pi-hole/docker-pi-hole on GitHub
  4. pihole/pihole on Docker Hub

Related

Comments