Why I Built This Setup
A few years ago, I started noticing DNS queries being logged and potentially monetized by my ISP. I also wanted better ad-blocking across my entire home network without configuring every device individually. Traditional DNS runs in plain text, which means anyone between you and the DNS server can see what domains you’re requesting.
I already ran Pi-hole for network-wide ad blocking on my Proxmox server, but the DNS queries themselves were still unencrypted once they left my network. I wanted to close that gap without adding complexity or relying on third-party services I couldn’t inspect.
The goal was simple: encrypt DNS queries leaving my network while keeping Pi-hole’s filtering intact, and do it all locally without sending my browsing patterns to public DoH providers.
My Real Setup
I run everything in Docker containers on a Proxmox VM. The stack consists of:
- Pi-hole – Handles DNS filtering and ad blocking
- Unbound – Acts as a recursive DNS resolver, so I’m not relying on upstream providers like Google or Cloudflare for resolution
- Caddy – Reverse proxy that automatically handles HTTPS certificates for the Pi-hole web interface
- dnsproxy (AdGuard) – Lightweight DNS-over-HTTPS forwarder that encrypts queries leaving my network
All of this runs on a single VM with 2GB RAM and 2 CPU cores. The containers are defined in a docker-compose.yml file, making the entire setup reproducible.
Network Flow
Here’s how DNS queries move through my setup:
- A device on my network sends a DNS query to Pi-hole (192.168.x.x:53)
- Pi-hole checks its blocklists and cache
- If the domain isn’t blocked or cached, Pi-hole forwards the query to Unbound (port 5335)
- Unbound recursively resolves the query by querying root DNS servers directly
- For external queries, dnsproxy encrypts them using DNS-over-HTTPS before they leave my network
This means my ISP sees encrypted HTTPS traffic on port 443, not plain DNS queries on port 53.
Docker Compose Configuration
I use Docker Compose to manage all four containers. Here’s the structure I settled on after testing different network configurations:
services:
pihole:
image: pihole/pihole:latest
ports:
- "53:53/tcp"
- "53:53/udp"
- "8080:80/tcp"
environment:
- TZ=America/New_York
- WEBPASSWORD=your_password_here
- DNS1=unbound#5335
- DNS2=no
volumes:
- ./pihole/etc:/etc/pihole
- ./pihole/dnsmasq:/etc/dnsmasq.d
networks:
- dns_network
restart: unless-stopped
unbound:
image: mvance/unbound:latest
ports:
- "5335:53/tcp"
- "5335:53/udp"
volumes:
- ./unbound:/opt/unbound/etc/unbound
networks:
- dns_network
restart: unless-stopped
caddy:
image: caddy:latest
ports:
- "443:443/tcp"
- "80:80/tcp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- dns_network
restart: unless-stopped
dnsproxy:
image: adguard/dnsproxy:latest
command:
- -l
- "0.0.0.0"
- -p
- "5053"
- --https-port=443
- -u
- "https://cloudflare-dns.com/dns-query"
ports:
- "5053:5053/tcp"
- "5053:5053/udp"
networks:
- dns_network
restart: unless-stopped
networks:
dns_network:
driver: bridge
volumes:
caddy_data:
caddy_config:
Caddyfile for HTTPS
Caddy handles automatic HTTPS certificates and reverse proxies the Pi-hole admin interface. My Caddyfile looks like this:
dns.mydomain.com {
reverse_proxy pihole:80
tls {
dns cloudflare YOUR_API_TOKEN
}
}
I use Cloudflare’s DNS API for certificate validation since my Pi-hole VM isn’t directly exposed to the internet. Caddy automatically renews certificates before they expire.
What Worked
Unbound as the Recursive Resolver
Using Unbound instead of forwarding to public DNS servers (like 8.8.8.8 or 1.1.1.1) gave me two benefits:
- No single upstream provider sees all my DNS queries
- Faster responses for frequently accessed domains due to local caching
I configured Unbound with a simple unbound.conf that enables DNSSEC validation and sets reasonable cache sizes:
server:
interface: 0.0.0.0
port: 53
do-ip4: yes
do-ip6: no
do-udp: yes
do-tcp: yes
access-control: 0.0.0.0/0 allow
verbosity: 1
hide-identity: yes
hide-version: yes
harden-glue: yes
harden-dnssec-stripped: yes
cache-min-ttl: 3600
cache-max-ttl: 86400
prefetch: yes
dnsproxy for DoH Encryption
I initially tried configuring Pi-hole to use DoH directly, but it doesn’t natively support it. Adding dnsproxy as a lightweight intermediary solved this cleanly. It listens on port 5053 and forwards queries over HTTPS to Cloudflare’s DoH endpoint.
The key was making sure dnsproxy didn’t interfere with Pi-hole’s filtering. By placing it after Unbound in the chain, Pi-hole still handles all blocking decisions before queries are encrypted and sent upstream.
Caddy for Automatic HTTPS
Caddy’s automatic certificate management removed the headache of manually renewing Let’s Encrypt certificates. I pointed a subdomain (dns.mydomain.com) to my home IP, and Caddy handled the rest.
One thing I learned: if you’re behind CGNAT or can’t open port 80/443, use Caddy’s DNS challenge instead of HTTP challenge. I use Cloudflare’s API for this.
What Didn’t Work
Exposing dnsproxy Publicly
I initially considered making my DoH endpoint accessible from outside my network so I could use it on my phone while traveling. This was a bad idea for several reasons:
- Opening DNS ports to the internet invites abuse and DDoS amplification attacks
- Managing authentication for public DoH is more complex than it’s worth
- My home upload bandwidth isn’t sufficient for reliable external DNS
I abandoned this approach and instead use WireGuard VPN when I need secure DNS away from home.
Docker Network Isolation Issues
My first attempt used Docker’s default bridge network, and containers couldn’t reliably communicate by name. DNS resolution between containers was inconsistent, causing Pi-hole to fail when trying to reach Unbound.
Creating a dedicated dns_network bridge in the compose file fixed this. Docker’s embedded DNS now correctly resolves container names like unbound and pihole within that network.
Logging and Disk Space
Pi-hole logs every query by default, which filled up my VM’s disk faster than expected. I now rotate logs weekly and limit retention to 7 days:
docker exec pihole pihole -f
I also disabled query logging for specific internal domains (like my Synology NAS) to reduce noise.
Performance and Observations
After running this setup for several months, I’ve noticed:
- DNS resolution is faster than using my ISP’s resolvers, likely due to Unbound’s caching
- Ad blocking is more effective than browser extensions alone, especially for smart TVs and IoT devices
- The VM uses about 1GB RAM under normal load, with CPU usage barely registering
- Certificate renewal happens automatically without intervention
I monitor the stack with Uptime Kuma running on the same Proxmox host, which alerts me if any container goes down.
Key Takeaways
- DNS encryption is simpler than it looks. dnsproxy handles DoH with minimal configuration.
- Unbound adds real privacy value. Not relying on a single upstream provider reduces tracking risk.
- Don’t expose DNS publicly unless you know what you’re doing. Use a VPN instead.
- Docker Compose makes this reproducible. I can spin up the entire stack on a new VM in under 5 minutes.
- Caddy’s automatic HTTPS is worth it. No more manual certificate renewal scripts.
Limitations and Trade-offs
This setup isn’t perfect:
- If my VM goes down, my entire network loses DNS until I restart it
- Unbound’s recursive resolution is slightly slower than forwarding to fast public resolvers
- I’m still trusting Cloudflare for the final DoH hop (though I could switch to another provider or run my own)
- Devices that hardcode DNS (like some smart TVs) bypass this entirely unless I block port 53 at the firewall
Despite these trade-offs, the privacy and control benefits outweigh the downsides for my use case.
What I’d Change Next Time
If I were starting over, I’d consider:
- Running a secondary Pi-hole instance for redundancy
- Testing AdGuard Home as an alternative to Pi-hole + dnsproxy
- Implementing stricter firewall rules to force all devices to use my DNS
- Exploring DNS-over-TLS (DoT) instead of DoH for slightly better performance
For now, this setup works reliably and gives me the DNS privacy I wanted without adding complexity I don’t need.