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.