Pi-hole v6 with Unbound - Docker Compose Setup

After running various DNS solutions in my homelab, I decided to implement a robust Pi-hole v6 setup with unbound as the recursive DNS resolver. This combination gives me ad-blocking capabilities while maintaining privacy by not relying on external DNS providers for resolution.

Pi-hole acts as a DNS sinkhole that blocks advertisements and tracking domains, while unbound serves as a validating, recursive, and caching DNS resolver. Together, they provide a self-contained DNS solution that doesn’t leak queries to third-party providers.

Overview

Here’s what we’ll be building:

Client Device
      ↓
   Pi-hole (192.168.7.250:53)
      ↓
   unbound (127.0.0.1:5335)
      ↓
   Root DNS Servers

The setup uses Docker Compose with host networking to run on a dedicated network interface, ensuring proper client IP visibility in Pi-hole logs and optimal performance.

Architecture

Pi-hole Container:

  • Runs the web interface and DNS filtering
  • Binds to dedicated network interface (eth1)
  • Forwards recursive queries to unbound
  • Maintains blocklists and custom DNS records

Unbound Container:

  • Built from Alpine Linux with custom Dockerfile
  • Handles DNSSEC validation
  • Performs recursive DNS resolution
  • Communicates with Pi-hole via localhost

Host Network Configuration:

  • Dedicated interface (eth1) for DNS services
  • Firewall rules restricting access to required ports only
  • Isolated from other network traffic

Prerequisites

  • Docker and Docker Compose installed
  • Dedicated network interface configured
  • Basic understanding of DNS concepts

Network Interface Setup

First, configure the dedicated network interface for Pi-hole:

# Configure eth1 interface
sudo nano /etc/network/interfaces.d/50-cloud-init

Add the interface configuration:

auto eth1
iface eth1 inet static
    address 192.168.7.250/24
    gateway 192.168.7.1

Apply the configuration:

sudo systemctl restart networking

Firewall Configuration

Configure nftables to restrict access to the DNS interface:

sudo nano /etc/nftables.conf
#!/usr/sbin/nft -f

flush ruleset

table inet filter {
    chain input {
        type filter hook input priority filter;

        # Allow loopback
        iif lo accept

        # Accept everything not on eth1 (default eth0, docker ...)
        iifname != "eth1" accept

        # Allow HTTP, HTTPS, DNS on eth1 with IP 192.168.7.250
        iif "eth1" ip daddr 192.168.7.250 tcp dport { 80, 443, 53 } accept
        iif "eth1" ip daddr 192.168.7.250 udp dport 53 accept

        # Drop everything else on eth1
        iif "eth1" drop
    }
    chain forward {
        type filter hook forward priority filter;
        policy accept;
    }
    chain output {
        type filter hook output priority filter;
        policy accept;
    }
}

Enable the firewall:

sudo nft --check -f /etc/nftables.conf
sudo nft -f /etc/nftables.conf
sudo systemctl enable nftables

Docker Compose Configuration

Create the project directory structure:

mkdir -p ~/docker/pihole-unbound/{unbound-build,unbound}
cd ~/docker/pihole-unbound

Main docker-compose.yml

services:
  unbound:
    build:
      context: ./unbound-build
      dockerfile: Dockerfile
    container_name: unbound
    network_mode: host
    volumes:
      - ./unbound/unbound.conf:/etc/unbound/unbound.conf:ro
      - ./unbound/unbound.log:/var/log/unbound/unbound.log:rw
    cap_add:
      - NET_ADMIN
    restart: unless-stopped

  pihole:
    container_name: pihole
    image: pihole/pihole:latest
    hostname: ${HOSTNAME}
    domainname: ${DOMAIN_NAME}
    network_mode: host
    environment:
      - TZ=${TZ:-UTC}
      - FTLCONF_webserver_api_password=${WEBPASSWORD}
      - FTLCONF_webserver_interface_theme=${WEBTHEME:-default-dark}
      - FTLCONF_webserver_port=${WEB_PORT}
      - FTLCONF_dns_domain=${DNS_DOMAIN}
      - FTLCONF_dns_interface=${DNS_INTERFACE}
      - FTLCONF_dns_listeningMode=BIND
      - FTLCONF_dns_dnssec=true
      - FTLCONF_dns_upstreams=127.0.0.1#5335
      - FTLCONF_dns_bogusPriv=true
      - FTLCONF_dns_domainNeeded=true
    volumes:
      - pihole-unbound-etc:/etc/pihole:rw
      - pihole-unbound_dnsmasq-etc:/etc/dnsmasq.d:rw
    cap_add:
      - SYS_NICE
    depends_on:
      - unbound
    restart: unless-stopped

volumes:
  pihole-unbound-etc:
  pihole-unbound_dnsmasq-etc:

Environment Variables

Create .env file:

TZ=Europe/Berlin
WEBPASSWORD=
HOSTNAME=dns-primary
DOMAIN_NAME=dns-primary.local
WEBTHEME=default-dark
WEB_PORT=192.168.7.250:80
DNS_INTERFACE=eth1
DNS_DOMAIN=local

Unbound Configuration

Dockerfile for unbound

# unbound-build/Dockerfile
FROM alpine:latest

# Install Unbound and required utilities
RUN apk add --no-cache unbound bash curl bind-tools

# Create necessary directories
RUN mkdir -p /etc/unbound /var/lib/unbound /var/log/unbound

# Set up directory permissions
RUN chown -R unbound:unbound /etc/unbound && \
    chmod 775 /etc/unbound && \
    chown -R unbound:unbound /var/lib/unbound && \
    chmod 775 /var/lib/unbound && \
    chown -R unbound:unbound /var/log/unbound && \
    chmod 775 /var/log/unbound

# Add startup script
COPY start.sh /start.sh
RUN chmod +x /start.sh

# Expose DNS port
EXPOSE 5335/tcp 5335/udp

# Use startup script
CMD ["/start.sh"]

Startup Script

#!/bin/bash
# unbound-build/start.sh

# Download latest root hints
echo "Downloading root hints file..."
curl -o /var/lib/unbound/root.hints https://www.internic.net/domain/named.cache || \
curl -o /var/lib/unbound/root.hints https://www.iana.org/domains/root/files/root.zone

# Set up DNSSEC trust anchor
echo "Setting up DNSSEC trust anchor..."
unbound-anchor -a "/var/lib/unbound/root.key"

# Ensure proper permissions
chown -R unbound:unbound /etc/unbound
chown -R unbound:unbound /var/lib/unbound
chown -R unbound:unbound /var/log/unbound

echo "DEBUG - List /etc/unbound"
ls -la /etc/unbound
echo "DEBUG - List /var/lib/unbound"
ls -la /var/lib/unbound

echo "Starting Unbound..."
exec /usr/sbin/unbound -d -c /etc/unbound/unbound.conf

Unbound Configuration

# unbound/unbound.conf
server:
    logfile: "/var/log/unbound/unbound.log"
    verbosity: 1

    interface: 127.0.0.1
    port: 5335
    do-ip4: yes
    do-udp: yes
    do-tcp: yes
    do-ip6: no
    prefer-ip6: no

    root-hints: "/var/lib/unbound/root.hints"
    auto-trust-anchor-file: "/var/lib/unbound/root.key"

    harden-glue: yes
    harden-dnssec-stripped: yes
    use-caps-for-id: no
    edns-buffer-size: 1232
    prefetch: yes
    num-threads: 1
    so-rcvbuf: 1m

    # Access control
    access-control: 127.0.0.1/32 allow
    access-control: 192.168.0.0/16 allow
    access-control: 172.16.0.0/12 allow
    access-control: 10.0.0.0/8 allow
    access-control: fc00::/7 allow
    access-control: ::1/128 allow

    # Private network protection
    private-address: 192.168.0.0/16
    private-address: 169.254.0.0/16
    private-address: 172.16.0.0/12
    private-address: 10.0.0.0/8
    private-address: fd00::/8
    private-address: fe80::/10

    # Performance optimization
    msg-cache-size: 32m
    rrset-cache-size: 64m
    cache-min-ttl: 3600
    cache-max-ttl: 86400
    serve-expired: yes
    serve-expired-ttl: 86400
    prefetch-key: yes
    aggressive-nsec: yes

    # Privacy
    hide-identity: yes
    hide-version: yes

Deployment

Deploy the containers:

# Build and start services
docker compose up --build -d

# Verify containers are running
docker compose ps

# Check logs
docker compose logs -f

Verification

Test the DNS resolution:

# Test DNSSEC validation
dig fail01.dnssec.works @192.168.7.250 -p 53
dig dnssec.works @192.168.7.250 -p 53

# Test ad blocking
dig ads.example.com @192.168.7.250 -p 53

# Test recursive resolution
dig example.com @192.168.7.250 -p 53

Access the Pi-hole web interface at http://192.168.7.250/admin.

Learnings

Docker Networking: Using host networking mode is essential for Pi-hole to see actual client IP addresses instead of the Docker bridge IP. This is crucial for proper logging and access control. Firewall Integration: The nftables configuration allows fine-grained control over which services are accessible on the DNS interface while keeping other interfaces fully functional. Volume Naming: Docker Compose automatically names volumes using the pattern <project-name>_<service-name>_<volume-name>. You can override the project name with the -p flag if needed. Container Communication: Containers using host networking can communicate via localhost, which is why unbound listens on 127.0.0.1:5335 and Pi-hole can reach it there.

This setup provides a robust, privacy-focused DNS solution that blocks ads while performing recursive DNS resolution without relying on external DNS providers. The configuration ensures proper client visibility and network isolation while maintaining high performance.