DNS over HTTPS & Fewer Ads
Lab fun with Docker, Pi-Hole, Nginx Proxy Manager, and Cloudflare Tunnels.
My goal was to create a DNS over HTTPS Docker-compose stack that is portable and easy to deploy.
With DNS over HTTPS (DoH), DNS queries and responses are encrypted and sent via the HTTP or HTTP/2 protocols. DoH ensures that attackers cannot forge or alter DNS traffic. DoH uses port 443, which is the standard HTTPS traffic port, to wrap the DNS query in an HTTPS request. DNS queries and responses are camouflaged within other HTTPS traffic since it all comes and goes from the same port.
https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/
In today’s landscape, original ideas are scarce; I’ve heavily drawn from the exceptional work of others. Specifically:
- James Turnland of YouTube Jim’s Garage fame showed me it works. Love your channel, Jim!
- Michael Roach’s post on Pi-hole and cloudflared with Docker, is so good that I find myself torn between admiration and the fear of inadvertently plagiarizing, my intention is solely to express sincere flattery.
Bits and Bobs
- Docker host – in my case an Ubuntu 22.04 server VM running on Proxmox
- Ubuntu port 53 binding fix
- Docker Compose for the control
- Portainer for the visibility
- Docker Macvlan for the networking flexibility
- Services
- Cloudflared
- Tunnel-up
- Tunnel-dns (proxy-dns)
- Pi-Hole as a DNS server and network-wide online ad protection.
- Nginx Proxy Manager to secure local services with Let’s Encrypt certificates.
- Cloudflared
Configuration
If you choose to run Ubuntu server as I do, it normally blocks Port 53 which is crucial for DNS resolution. The fix is to apply these commands at the CLI:
sudo sed -r -i.orig 's/#?DNSStubListener=yes/DNSStubListener=no/g' /etc/systemd/resolved.conf
sudo sh -c 'rm /etc/resolv.conf && ln -s /run/systemd/resolve/resolv.conf /etc/resolv.conf'
sudo systemctl restart systemd-resolved
Normally, Docker services are confined to an internal network with specific ports exposed. But with the Macvlan network driver, one can provide an IP Address to each service. Both worked as expected – here’s the Macvlan option.
#---------------------------------------
# Docker macvlan example
# Must run this on Docker host prior to
# deploying the stack
#---------------------------------------
docker network create -d macvlan \
--subnet=192.168.10.0/24 \
--gateway=192.168.10.1 \
-o parent=eth0 doh_lan
For portability, I elected to use a Docker Compose environment file.
.env
#---------------------------
# Docker-compose parameters
#---------------------------
# Common
TZ=America_New_York
PREFIX=onlan
# Service: Cloudflared (part 1 and 2)
CF1_ORDER=1 # numbers the services
C1_NAME=tunnel-up
TUNNEL_UP_IP:192.168.10.11
CF2_ORDER=2
C2_NAME=tunnel-dns
CMD_TUNNEL='tunnel --no-autoupdate run --token <YOUR CLOUDFLARED TOKEN HERE>'
DNS_TUNNEL_IP=192.168.10.12
# Service: PiHole
PI_ORDER=3
PI_NAME=pihole
WEB_PORT=80
WEBPASSWORD=changeme
DNS1=192.168.10.12#53
DNS2=no
PI_IP=192.168.10.13
# Service (npm) nginx proxy manager
NPM_ORDER=4
NPM_NAME=proxy
NPM_IP=192.168.10.14
docker-compose.yml
#-------------------------------------------------------------------------
# Docker Stack
#--------------------------------------------------------------------------
# Services:
# 1. Cloudflared-tunnel
# 2. Cloudflared-dns
# 3. Pi-Hole
# 4. NPM (Nginx Proxy Manager)
#--------------------------------------------------------------------------
version: '3.9'
services:
# Creates a connection to a Cloudflare Zero-Trust Tunnel
cloudflared-tunnel:
container_name: ${CF1_ORDER}-${PREFIX}-${C1_NAME}
command: ${CMD_TUNNEL}
image: 'cloudflare/cloudflared:latest'
restart: unless-stopped
networks:
doh_lan:
ipv4_address: ${TUNNEL_UP_IP}
# Layers on the Cloudflared proxy-dns command
cloudflared-dns:
container_name: ${CF2_ORDER}-${PREFIX}-${C2_NAME}
depends_on:
- "cloudflared-tunnel"
restart: unless-stopped
image: cloudflare/cloudflared
depends_on:
- "cloudflared-tunnel"
command: proxy-dns
environment:
- "TUNNEL_DNS_UPSTREAM=https://1.1.1.1/dns-query,https://1.0.0.1/dns-query,https://9.9.9.9/dns-query,https://149.112.112.9/dns-query"
- "TUNNEL_METRICS=0.0.0.0:49312"
- "TUNNEL_DNS_ADDRESS=0.0.0.0"
- "TUNNEL_DNS_PORT=53"
sysctls:
- net.ipv4.ip_unprivileged_port_start=53
networks:
doh_lan:
ipv4_address: ${DNS_TUNNEL_IP}
pihole:
image: pihole/pihole
container_name: ${PI_ORDER}-${PREFIX}-${PI_NAME}
hostname: ${PREFIX}${PI_NAME}
restart: unless-stopped
depends_on:
- "cloudflared-dns"
environment:
- "TZ=${TZ}"
- "DNS1=${DNS1}"
- "DNS2=${DNS2}"
- "DNSMASQ_LISTENING=all"
- "WEBPASSWORD=${WEBPASSWORD}"
- "WEB_PORT=${WEB_PORT}"
volumes:
- '/home/docker/pihole/config:/etc/pihole/'
- '/home/docker/pihole/dnsmasq:/etc/dnsmasq.d/'
networks:
doh_lan:
ipv4_address: ${PI_IP}
# Nginx Proxy Manager and Certificate Management
npm:
image: 'jc21/nginx-proxy-manager:latest'
container_name: ${NPM_ORDER}-${PREFIX}-${NPM_NAME}
restart: unless-stopped
depends_on:
- "pihole"
ports:
- '80:80'
- '81:81'
- '443:443'
volumes:
- /home/docker/npm/data:/data
- /home/docker/npm/letsencrypt:/etc/letsencrypt
networks:
doh_lan:
ipv4_address: ${NPM_IP}
networks:
doh_lan:
external:
name: doh_lan
Because of persistent issues in getting the Cloudflared service to consistently establish the tunnel, particularly after a Docker host reboot, I opted to run two instances: one for tunnel establishment (tunnel-up) and the other for executing the dns-proxy command.
Adapting this configuration is easy due to the parameters in the .env file. Notice how each service depends on the one before to control boot order.
Switching to another proxy server like Traefik or Caddy should be straightforward.
Resulting view from Portainer:
Beyond being an ad-blocker, Pi-hole is my favorite DNS server and has been rock-solid here for years.
Hope this helps you as much as it was fun for me. Feel free to comment.