How to Deploy Pi-hole with Unbound Recursive DNS in Docker Compose for Total Network Ad-Blocking

Pi-hole mit Unbound DNS in Docker Compose einrichten - Systemadministrator im Rechenzentrum vor Netzwerk-Monitoring-Dashboards
How to Deploy Pi-hole with Unbound Recursive DNS in Docker Compose for Total Network Ad-Blocking 3

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 for github.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):

  1. Navigate to your router’s LAN / DHCP Server configuration tab.
  2. Locate the Primary DNS Server field and enter your Docker host’s static LAN IP (e.g., 192.168.1.100).
  3. 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.
  4. Renew DHCP leases on client workstations or reconnect to the Wi-Fi network.
  5. Access the Pi-hole administration dashboard by navigating to http://<DOCKER-HOST-IP>:8080/admin and log in using the password specified in WEBPASSWORD.

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.