
Every time a device on your network loads a website, streams media, or executes an API call, it issues a Domain Name System (DNS) query. While modern ad-blocking tools such as standalone Pi-hole or AdGuard Home effectively filter telemetry endpoints and marketing trackers, they typically forward remaining non-blocked queries to upstream commercial resolvers like Cloudflare (1.1.1.1), Google (8.8.8.8), or Quad9 (9.9.9.9). Even when transmitted over encrypted channels (DNS-over-HTTPS or DNS-over-TLS), relying on upstream resolvers consolidates your entire household or enterprise browsing profile in the hands of an external service provider.
Deploying Pi-hole alongside Unbound creates a self-contained, sovereign DNS infrastructure. Pi-hole acts as the front-facing sinkhole and local cache that drops unwanted advertisements, telemetry, and malware domains. Unbound acts as a dedicated recursive DNS resolver: rather than querying third parties, Unbound communicates directly with the Internet’s 13 root name server clusters (a.root-servers.net through m.root-servers.net), the Top-Level Domain (TLD) authoritative servers, and finally the domain’s authoritative nameserver. In this guide, you will learn how to deploy a production-grade Pi-hole and Unbound stack using Docker Compose, complete with DNSSEC validation, custom bridge networking, performance tuning, and robust troubleshooting workflows.
Architectural Overview: Sinkholing Meets Root Recursion
In standard network configurations, client devices query Pi-hole on port 53. If a domain is found on a configured gravity adlist or custom blacklist, Pi-hole immediately responds with an empty IP (0.0.0.0) or an NXDOMAIN status code. If the domain is legitimate, Pi-hole passes the request to an upstream resolver. In our dual-container setup, that upstream resolver is Unbound running inside the same isolated Docker bridge network.
+-----------------------------------------------------------------------------------+
| LOCAL NETWORK CLIENTS |
| (Desktops, Phones, IoT, Servers) |
+-----------------------------------------------------------------------------------+
|
| DNS Query (UDP/TCP Port 53)
v
+-----------------------------------------------------------------------------------+
| DOCKER HOST: bridge network (172.20.0.0/24) |
| |
| +------------------------------------+ |
| | Container 1: Pi-hole (172.20.0.2) | |
| | - Port 53 (Exposed to Host LAN) | |
| | - Web GUI Port 8080 (Lighttpd) | |
| | - Ad-block lists (Gravity DB) | |
| | - Local DNS Records & DHCP | |
| +------------------------------------+ |
| | |
| | Forwarding Allowed Queries (Internal Port 5335) |
| v |
| +------------------------------------+ |
| | Container 2: Unbound (172.20.0.3) | |
| | - Recursive Resolver Engine | |
| | - DNSSEC Cryptographic Validation | |
| | - QNAME Minimisation (Privacy) | |
| | - Pre-fetching & RRset Cache | |
| +------------------------------------+ |
+-----------------------------------------------------------------------------------+
| | |
v v v
[ 13 Root Servers ] [ TLD Nameservers ] [ Authoritative NS ]
( .root-servers ) ( .com, .org, .net ) ( ns1.example.com )
By delegating resolution directly to root and authoritative nameservers, you achieve three decisive advantages:
- Zero Upstream Telemetry: No centralized corporate resolver logs your query volume, browsing patterns, or timestamps.
- DNSSEC Cryptographic Verification: Unbound validates digital signatures (RRSIG records) against the root zone trust anchor (DS records), preventing DNS spoofing and cache poisoning attacks.
- QNAME Minimisation (RFC 7816): Unbound sends only the minimal domain path necessary to each hierarchical authority. When resolving
api.github.com, root servers only receive a request for.com, top-level domain servers receive a request forgithub.com, and only the authoritative server receives the full subdomain query.
Prerequisites and System Preparation
Before deploying the containers on an Ubuntu or Debian server, you must resolve a frequent port conflict. Modern Linux distributions enable systemd-resolved by default, which binds a local DNS stub listener to 127.0.0.53:53. Because Pi-hole must bind directly to host port 53 on all network interfaces (0.0.0.0:53), the stub listener must be disabled.
Verify whether port 53 is currently in use on your host:
sudo ss -tulpn | grep ':53 '
If systemd-resolved appears in the output, modify its configuration to disable the stub listener without breaking host-level name resolution:
# Backup current configuration
sudo cp /etc/systemd/resolved.conf /etc/systemd/resolved.conf.bak
# Disable DNSStubListener and point DNS to localhost
sudo sed -r -i.orig 's/#?DNSStubListener=yes/DNSStubListener=no/g' /etc/systemd/resolved.conf
# Recreate symlink to maintain static resolv.conf
sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf
# Restart systemd-resolved
sudo systemctl restart systemd-resolved
Create the directory structure for your project stack:
mkdir -p ~/pihole-unbound/unbound
mkdir -p ~/pihole-unbound/pihole
cd ~/pihole-unbound
Step 1: Hardened Unbound Configuration
Create the file unbound/unbound.conf. This configuration instructs Unbound to run on port 5335, enforces strict DNSSEC validation, enables QNAME minimisation, and tunes in-memory caching for low-latency queries:
server:
# Bind to all container interfaces on port 5335
interface: 0.0.0.0@5335
do-ip4: yes
do-udp: yes
do-tcp: yes
do-ip6: no
# Access control: permit the Docker subnet and localhost
access-control: 127.0.0.0/8 allow
access-control: 172.16.0.0/12 allow
access-control: 192.168.0.0/16 allow
access-control: 10.0.0.0/8 allow
# Security and Privacy Hardening
hide-identity: yes
hide-version: yes
qname-minimisation: yes
harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: no
# Trust Anchor for DNSSEC
auto-trust-anchor-file: "/var/lib/unbound/root.key"
# Performance and Cache Optimization
num-threads: 1
msg-cache-slabs: 2
rrset-cache-slabs: 2
infra-cache-slabs: 2
key-cache-slabs: 2
# Memory allocation (scale up for higher traffic)
rrset-cache-size: 64m
msg-cache-size: 32m
# Prefetching frequently queried items before expiry
prefetch: yes
prefetch-key: yes
serve-expired: yes
serve-expired-ttl: 3600
# Ensure privacy for RFC 1918 private IP ranges
private-address: 10.0.0.0/8
private-address: 172.16.0.0/12
private-address: 192.168.0.0/16
private-address: 169.254.0.0/16
private-address: 127.0.0.0/8
Step 2: Production-Ready Docker Compose File
Now create docker-compose.yml in your root directory (~/pihole-unbound/docker-compose.yml). We assign static IP addresses inside a dedicated user-defined bridge network (dns_net). This guarantees that Pi-hole can reliably reach Unbound at 172.20.0.3#5335 regardless of container reboot sequence.
services:
unbound:
image: mvance/unbound:latest
container_name: unbound
restart: unless-stopped
hostname: unbound
volumes:
- ./unbound/unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro
networks:
dns_net:
ipv4_address: 172.20.0.3
healthcheck:
test: ["CMD-SHELL", "drill -p 5335 @127.0.0.1 cloudflare.com || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
- SETUID
- SETGID
pihole:
image: pihole/pihole:latest
container_name: pihole
restart: unless-stopped
hostname: pihole
depends_on:
unbound:
condition: service_healthy
ports:
# DNS standard ports bound to all host interfaces
- "53:53/tcp"
- "53:53/udp"
# Web interface port (offset to 8080 to prevent conflicts with web servers)
- "8080:80/tcp"
environment:
TZ: 'UTC'
WEBPASSWORD: 'ReplaceWithStrongSecurePassword123!'
FTLCONF_LOCAL_IPV4: '192.168.1.100' # Replace with your host IP
PIHOLE_DNS_: '172.20.0.3#5335'
DNSSEC: 'true'
DNS_BOGUS_PRIV: 'true'
DNS_FQDN_REQUIRED: 'true'
REV_SERVER: 'false'
volumes:
- ./pihole/etc-pihole:/etc/pihole
- ./pihole/etc-dnsmasq.d:/etc/dnsmasq.d
networks:
dns_net:
ipv4_address: 172.20.0.2
cap_add:
- NET_ADMIN
healthcheck:
test: ["CMD", "dig", "+norecurse", "+retry=0", "@127.0.0.1", "pi.hole"]
interval: 30s
timeout: 5s
retries: 3
networks:
dns_net:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/24
gateway: 172.20.0.1
Step 3: Launching and Bootstrapping the Stack
Launch both services in detached mode using Docker Compose:
docker compose up -d
Monitor the startup sequence and ensure the health checks pass:
docker compose ps
Both containers should transition to healthy within 15 seconds. If you need to view the live query logs or troubleshoot startup errors, inspect the container output:
docker compose logs -f pihole
docker compose logs -f unbound
Step 4: Verification and DNSSEC Validation Tests
Once the containers are running, execute direct verification tests from your terminal to confirm both recursive resolution and cryptographic DNSSEC enforcement.
1. Testing Recursive DNS Resolution
Query Pi-hole on port 53 for an external domain. The response must include an ANSWER SECTION and the IP address of the queried target:
dig @127.0.0.1 -p 53 example.com +noall +answer
Next, query Unbound directly on port 5335 inside the container network to ensure root recursion functions independently of Pi-hole:
docker exec -it pihole dig @172.20.0.3 -p 5335 kernel.org +short
2. Cryptographic DNSSEC Verification
To verify that Unbound correctly rejects tampered or fraudulent DNS records, test a domain with an intentionally invalid DNSSEC signature maintained by DNS-OARC (sigfail.verteiltesysteme.net):
dig @127.0.0.1 -p 53 sigfail.verteiltesysteme.net
The command must return an error status of SERVFAIL with no IP address in the answer section. This confirms that Unbound detected the fraudulent signature and blocked the resolution.
Now query a properly signed DNSSEC domain (sigok.verteiltesysteme.net):
dig @127.0.0.1 -p 53 sigok.verteiltesysteme.net +dnssec
The output must return NOERROR, an authentic IP address, and the ad (Authenticated Data) flag set in the header flags line (flags: qr rd ra ad).
Step 5: Router Configuration and Client Cutover
To direct all network devices through your newly deployed Pi-hole and Unbound resolver, access your primary router or DHCP server settings (e.g., OPNsense, pfSense, UniFi Dream Machine, or Fritz!Box):
- Navigate to your router’s LAN / DHCP Server configuration tab.
- Locate the Primary DNS Server field and enter your Docker host’s static LAN IP (e.g.,
192.168.1.100). - Leave the Secondary DNS Server blank or point it to a second identical Pi-hole instance. Never enter an external public DNS (like 8.8.8.8) as secondary, because operating systems will load-balance queries across both servers, causing ad-blocking leaks.
- Renew DHCP leases on client workstations or reconnect to the Wi-Fi network.
- Access the Pi-hole administration dashboard by navigating to
http://<DOCKER-HOST-IP>:8080/adminand log in using the password specified inWEBPASSWORD.
Troubleshooting Common Issues
1. “listen udp 0.0.0.0:53: bind: address already in use”
Cause: The host’s local DNS resolver daemon (such as systemd-resolved, dnsmasq, or an existing BIND9 instance) is already listening on port 53 of the host network interface.
Solution: Identify the process currently occupying the port using sudo lsof -i :53 or sudo ss -tulpn | grep :53. If the offending process is systemd-resolved, verify that DNSStubListener=no was saved in /etc/systemd/resolved.conf and that the service was restarted with sudo systemctl restart systemd-resolved.
2. SERVFAIL on All Domains or Broken DNSSEC
Cause: Unbound requires an accurate system clock to validate cryptographic certificate validity intervals and DNSSEC key timestamps. If the host machine’s clock drifts significantly, or if the DNS root key (/var/lib/unbound/root.key) is corrupted or inaccessible, all signed queries fail.
Solution: Ensure your Docker host synchronizes time via NTP by running timedatectl status. If NTP service: active is false, enable it via sudo timedatectl set-ntp true. Next, restart the Unbound container to force an initialization of the root trust anchor: docker compose restart unbound.
3. Pi-hole Dashboard Displays “DNSMASQ_WARN: Maximum number of concurrent DNS queries reached”
Cause: Under heavy load or during initial cold cache recursion, queries to Unbound can queue up, exceeding FTLDNS’s default concurrent query ceiling (typically 150 concurrent queries).
Solution: Create a custom dnsmasq configuration file at pihole/etc-dnsmasq.d/99-custom.conf containing:
# Increase max concurrent DNS queries from 150 to 500
dns-forward-max=500
# Cache size for FTL engine
cache-size=10000
Restart Pi-hole with docker compose restart pihole to apply the adjusted query queue limits.
Conclusion
Combining Pi-hole with Unbound inside Docker Compose delivers an enterprise-grade, privacy-respecting DNS resolver directly to your local infrastructure. By combining real-time domain sinkholing with authoritative root recursion, you eliminate third-party telemetry, enforce DNSSEC validation across all connected devices, and speed up routine network lookups through intelligent memory caching. With static container IP mapping and automated health checks in place, your local resolver is resilient, self-healing, and fully autonomous.
Hi, I’m Mark, the author of Clever IT Solutions: Mastering Technology for Success. I am passionate about empowering individuals to navigate the ever-changing world of information technology. With years of experience in the industry, I have honed my skills and knowledge to share with you. At Clever IT Solutions, we are dedicated to teaching you how to tackle any IT challenge, helping you stay ahead in today’s digital world. From troubleshooting common issues to mastering complex technologies, I am here to guide you every step of the way. Join me on this journey as we unlock the secrets to IT success.


