A complete guide to running Pi-hole in Docker with a DoH endpoint for Firefox. Your browser sends encrypted DNS queries to a local HTTPS endpoint, which forwards them to Pi-hole for ad-blocking and resolution.
Firefox (DoH) ──HTTPS:8443──▸ Caddy (:443) ──HTTP──▸ doh-server (:3000) ──DNS──▸ Pi-hole (:53)
└── all inside Docker bridge network ──┘
All three services run in a private Docker bridge network. Only port 8443 (DoH) and 8080 (admin UI) are exposed to the host. Port 53 stays internal, so systemd-resolved is untouched.
Prerequisites
sudo apt update && sudo apt install -y docker.io docker-compose-v2 libnss3-tools curl
sudo usermod -aG docker $USER
newgrp docker
1. Generate Locally-Trusted TLS Certificates
Firefox requires HTTPS for DoH and won’t silently accept self-signed certs. mkcert creates a local CA that your system and Firefox will trust.
# Install mkcert
curl -L -o mkcert "https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-amd64"
chmod +x mkcert
sudo mv mkcert /usr/local/bin/
# Create and install the local CA
mkcert -install
# Generate certs for localhost
mkdir -p ~/pihole-doh/certs
mkcert -cert-file ~/pihole-doh/certs/cert.pem \
-key-file ~/pihole-doh/certs/key.pem \
localhost 127.0.0.1
Note the CA root location for later (Firefox import):
mkcert -CAROOT
# e.g. /home/youruser/.local/share/mkcert
2. Create Configuration Files
Caddyfile
Caddy terminates TLS and proxies to the doh-server. auto_https must be off to prevent Caddy from trying to bind port 80 (which Pi-hole uses).
mkdir -p ~/pihole-doh
cat > ~/pihole-doh/Caddyfile << 'EOF'
{
auto_https off
}
:443 {
tls /certs/cert.pem /certs/key.pem
reverse_proxy doh-server:3000
}
EOF
Docker Compose
cat > ~/pihole-doh/docker-compose.yml << 'EOF'
services:
pihole:
image: pihole/pihole:latest
container_name: pihole
environment:
TZ: "America/New_York"
FTLCONF_webserver_api_password: "changeme"
# Lock Pi-hole's web server to port 80 only (HTTP).
# Without this it also grabs 443 and blocks Caddy.
FTLCONF_webserver_port: "80o"
volumes:
- pihole_etc:/etc/pihole
- pihole_dnsmasq:/etc/dnsmasq.d
ports:
- "8080:80/tcp" # Pi-hole admin UI
networks:
- pihole-net
restart: unless-stopped
doh-server:
image: satishweb/doh-server:latest
container_name: doh-server
environment:
UPSTREAM_DNS_SERVER: "udp:pihole:53"
DOH_HTTP_PREFIX: "/dns-query"
DOH_SERVER_LISTEN: "0.0.0.0:3000"
networks:
- pihole-net
restart: unless-stopped
depends_on:
- pihole
caddy:
image: caddy:latest
container_name: caddy
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./certs/cert.pem:/certs/cert.pem:ro
- ./certs/key.pem:/certs/key.pem:ro
ports:
- "8443:443/tcp" # DoH endpoint (HTTPS)
networks:
- pihole-net
restart: unless-stopped
depends_on:
- doh-server
networks:
pihole-net:
volumes:
pihole_etc:
pihole_dnsmasq:
EOF
Why a bridge network? Port 53 only exists inside the
pihole-netnetwork — it never touches the host, sosystemd-resolvedis unaffected. Containers reference each other by name (pihole,doh-server) via Docker’s built-in DNS. Only port 8443 (DoH) and 8080 (admin UI) are exposed to the host.
3. Start the Stack
cd ~/pihole-doh
docker compose up -d
Wait ~30 seconds for Pi-hole to initialize, then verify:
# Check all containers are running
docker compose ps
# Pi-hole admin UI
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/admin/
# Should return 200
# doh-server (from inside the network)
docker exec doh-server wget -qO- "http://localhost:3000/dns-query?name=google.com&type=A"
# Should return JSON with an IP address
# Full pipeline through Caddy (TLS)
curl -s "https://localhost:8443/dns-query?name=google.com&type=A"
# Should return the same JSON
If the Caddy curl fails, check the logs:
docker logs --tail 20 caddy
4. Import the CA into Firefox
Firefox uses its own certificate store and won’t trust the system CA automatically.
- Open Firefox → Settings → Privacy & Security
- Scroll to Certificates → View Certificates
- Go to the Authorities tab → Import
- Navigate to the CA root path from step 1:
/home/youruser/.local/share/mkcert/rootCA.pem - Check “Trust this CA to identify websites” → OK
5. Configure Firefox DoH
- Open Firefox → Settings → Privacy & Security
- Scroll to DNS over HTTPS
- Select Max Protection
- Set provider to Custom
- Enter:
https://localhost:8443/dns-query
Load any website — if it resolves, you’re done.
6. Verify Ad Blocking
- Visit
http://localhost:8080/adminand log in with the password you set - Open a site with ads (e.g. a news site) and check the Pi-hole dashboard for blocked queries
- You can also test directly:
curl -s "https://localhost:8443/dns-query?name=ads.example.com&type=A"
Quick Reference
| Component | Role | Container Port | Host Port |
|---|---|---|---|
| Pi-hole | DNS resolver + ad blocking | 53 (DNS) | — (internal only) |
| Pi-hole UI | Admin dashboard | 80 (HTTP) | 8080 |
| doh-server | DoH → plain DNS translator | 3000 (HTTP) | — (internal only) |
| Caddy | TLS termination | 443 (HTTPS) | 8443 |
Managing the Stack
cd ~/pihole-doh
# Stop everything
docker compose down
# Start everything
docker compose up -d
# View logs
docker compose logs -f
# Update images
docker compose pull && docker compose up -d
# Fully reset (deletes blocklists and config)
docker compose down -v
Troubleshooting
“Page not found” / Pi-hole HTML from the DoH endpoint
→ Pi-hole’s web server is stealing port 443. Verify FTLCONF_webserver_port is set to 80o and restart:
docker compose restart pihole caddy
Caddy won’t start — “address already in use” on port 80
→ auto_https off is missing from the Caddyfile global block.
Firefox shows “We’re having trouble finding that site”
→ The CA isn’t imported into Firefox, or the DoH URL is wrong. It must be https://localhost/dns-query (with scheme and path).
Port 53 “Address in use”
→ This shouldn’t happen with the bridge network setup. If you see it, make sure you’re not using network_mode: host. With bridge networking, port 53 lives entirely inside the Docker network and never conflicts with systemd-resolved.