Pi-hole + DNS-over-HTTPS (DoH) with Docker on Ubuntu 26.04

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-net network — it never touches the host, so systemd-resolved is 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.

  1. Open Firefox → Settings → Privacy & Security
  2. Scroll to Certificates → View Certificates
  3. Go to the Authorities tab → Import
  4. Navigate to the CA root path from step 1:
    /home/youruser/.local/share/mkcert/rootCA.pem
    
  5. Check “Trust this CA to identify websites” → OK

5. Configure Firefox DoH

  1. Open Firefox → Settings → Privacy & Security
  2. Scroll to DNS over HTTPS
  3. Select Max Protection
  4. Set provider to Custom
  5. Enter:
    https://localhost:8443/dns-query
    

Load any website — if it resolves, you’re done.


6. Verify Ad Blocking

  1. Visit http://localhost:8080/admin and log in with the password you set
  2. Open a site with ads (e.g. a news site) and check the Pi-hole dashboard for blocked queries
  3. 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.