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

**URL:** <https://discourse.draft13.com/t/pi-hole-dns-over-https-doh-with-docker-on-ubuntu-26-04/335>\
**Category:** General\
**Tags:** howto\
**Created:** [August 25, 2026, 6:33pm UTC](https://discourse.draft13.com/t/pi-hole-dns-over-https-doh-with-docker-on-ubuntu-26-04/335 "2026-08-25T18:33:56Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![rob](https://discourse.draft13.com/user_avatar/discourse.draft13.com/rob/32/3_2.png) [@rob](https://discourse.draft13.com/u/rob)\
**Post date:** [August 25, 2026, 6:33pm UTC](https://discourse.draft13.com/t/pi-hole-dns-over-https-doh-with-docker-on-ubuntu-26-04/335/1 "2026-08-25T18:33:56Z")

</div>

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.

```auto
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

```bash
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.

```bash
# 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):

```bash
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).

```bash
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

```bash
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

```bash
cd ~/pihole-doh
docker compose up -d

```

Wait ~30 seconds for Pi-hole to initialize, then verify:

```bash
# 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:

```bash
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:

```auto
/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:

```auto
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:

```bash
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

```bash
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:

```bash
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`.
