Private DNS with Pi-hole and Unbound: DNS Security for Self-Hosted Networks
I used to rely on public DNS resolvers—Cloudflare, Google, whatever—until I realized every DNS query I made was leaking metadata about my network. When I set up Pi-hole with Unbound last year, I gained three critical benefits: absolute ad-blocking at the DNS level, complete DNS query privacy, and the ability to create internal domain records for my homelab services. This isn't a luxury—it's a privacy baseline I can no longer live without.
In this guide, I'll walk you through deploying Pi-hole and Unbound together on a single machine or Docker Compose stack. By the end, you'll have a recursive DNS server that answers to no upstream provider, blocks malware and ads before they touch your devices, and gives you granular control over name resolution across your entire network.
Why Pi-hole and Unbound Together?
Pi-hole is excellent at blocking domains via blocklists and providing a web UI. But by default, it still forwards queries to upstream DNS providers (Cloudflare, Google, etc.). That means Pi-hole sees which domains you visit, but so does your upstream provider.
Unbound is a recursive DNS resolver. It queries the DNS root servers directly, validates responses using DNSSEC, and caches results. When you pair them, Pi-hole becomes your client-facing interface, and Unbound becomes the authoritative backend. No queries leave your network unless you explicitly want them to.
I prefer this architecture because:
- Zero upstream leaks: DNSSEC validation happens locally. Your ISP can't see what you're resolving.
- Ad-blocking at wire speed: Ads are blocked before reaching any device on the network.
- Split-horizon DNS: Internal hostnames (like
jellyfin.local) resolve correctly while external queries go recursive. - Predictable behavior: No dependency on third-party DNS providers or their rate limits.
Infrastructure: Where to Run This?
For a small homelab or home network, a Raspberry Pi 4 with 4GB RAM is sufficient. If you're running a larger deployment with hundreds of devices, consider a dedicated VPS or small dedicated server from RackNerd—they offer affordable KVM VPS options starting at a few dollars monthly, with plenty of bandwidth for recursive DNS traffic.
I prefer Docker Compose for this setup because it isolates both services, handles networking cleanly, and makes updates painless. You can also install both bare-metal if you prefer—I'll cover the Docker approach here since it's more portable.
Docker Compose Setup: Pi-hole + Unbound
Here's my production-ready Docker Compose configuration. This runs both services with proper networking, persistent data, and security best practices.
version: '3.8'
services:
unbound:
image: mvance/unbound:latest
container_name: unbound
restart: unless-stopped
ports:
- "5353:53/udp"
- "5353:53/tcp"
volumes:
- ./unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro
networks:
dns_net:
ipv4_address: 10.0.9.3
healthcheck:
test: ["CMD", "drill", "@127.0.0.1", "google.com"]
interval: 30s
timeout: 5s
retries: 3
pihole:
image: pihole/pihole:latest
container_name: pihole
restart: unless-stopped
ports:
- "53:53/udp"
- "53:53/tcp"
- "80:80/tcp"
- "443:443/tcp"
environment:
TZ: UTC
WEBPASSWORD: ${WEBPASSWORD}
DNS1: 10.0.9.3#5353
DNS2: 10.0.9.3#5353
DNSMASQ_LISTENING: all
volumes:
- ./pihole/etc-pihole:/etc/pihole
- ./pihole/etc-dnsmasq.d:/etc/dnsmasq.d
networks:
dns_net:
ipv4_address: 10.0.9.2
depends_on:
unbound:
condition: service_healthy
healthcheck:
test: ["CMD", "dig", "@127.0.0.1", "google.com"]
interval: 30s
timeout: 5s
retries: 3
networks:
dns_net:
driver: bridge
ipam:
config:
- subnet: 10.0.9.0/24
Before running this, create an .env file in the same directory:
WEBPASSWORD=your_secure_password_here
Then start the stack:
docker-compose up -d
openssl rand -base64 16.Configuring Unbound for Recursive Resolution
Create the unbound.conf file that the Docker Compose references. This configuration enables recursive resolution, DNSSEC validation, and sensible caching:
server:
port: 53
do-ip4: yes
do-ip6: yes
do-udp: yes
do-tcp: yes
# Performance tuning
num-threads: 4
msg-cache-size: 50m
rrset-cache-size: 100m
outgoing-range: 4096
# Security and privacy
hide-identity: yes
hide-version: yes
qname-minimisation: yes
use-caps-for-id: yes
prefetch: yes
prefetch-key: yes
# DNSSEC validation
module-config: "validator iterator"
auto-trust-anchor-file: "/opt/unbound/etc/unbound/root.key"
# Upstream servers for bootstrap (used only initially)
root-hints: "/opt/unbound/etc/unbound/root.hints"
# Local zone for internal resolution
local-zone: "local." static
local-data: "jellyfin.local. IN A 192.168.1.100"
local-data: "nextcloud.local. IN A 192.168.1.101"
local-data: "home.local. IN A 192.168.1.1"
# Access control - allow only local networks
access-control: 0.0.0.0/0 refuse
access-control: 127.0.0.1/32 allow
access-control: 192.168.0.0/16 allow
access-control: 10.0.0.0/8 allow
access-control: 172.16.0.0/12 allow
access-control: 10.0.9.0/24 allow
forward-zone:
name: "."
forward-addr: 8.8.8.8@53
forward-addr: 8.8.4.4@53
Notice the local-zone and local-data directives. This is split-horizon DNS in action—I'm telling Unbound to resolve jellyfin.local to my internal IP, while external queries go recursive through the root servers. Customize these entries for your homelab services.
Configuring Pi-hole for Blocklists and Management
Once the containers are running, navigate to http://your-machine-ip/admin and log in with your password. Go to Settings → DNS and verify that both DNS servers are pointing to Unbound:
- DNS Server 1:
10.0.9.3#5353 - DNS Server 2: (leave empty or use the same)
Next, add blocklists. I typically use:
https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts(comprehensive malware + ads)https://winhelp2002.mvps.org/hosts.txt(MVPS hosts file)https://adaway.org/hosts.txt(AdAway blocklist)
Go to Adlists, paste each URL, and enable them. Pi-hole will download and parse these into its blocking engine. The first sync can take a minute or two—watch the Tools → Gravity page to see progress.
I avoid overly aggressive blocklists that block legitimate domains. A few quality sources are better than dozens of low-quality ones. Test your setup by visiting a known ad network domain (like doubleclick.net) and confirming it resolves to 0.0.0.0.
Pointing Your Network to This DNS Server
For this to work across your homelab, every device needs to use your Pi-hole/Unbound server as its primary DNS resolver.
Option 1: DHCP (Recommended)
Log into your router and change the DHCP DNS settings to point to your Pi-hole machine's IP (e.g., 192.168.1.50). This pushes DNS config to all devices automatically.
Option 2: Static per-device
On each device (or in Docker containers), set DNS manually to your Pi-hole IP. In Docker Compose, add:
services:
my_app:
dns:
- 192.168.1.50
dns_search:
- local
Option 3: Internal network only
If you're running this on a remote VPS, configure only your homelab containers and local machines to use it. Public DNS clients will still use system defaults.
Monitoring and Maintenance
Pi-hole's dashboard shows real-time queries, blocked domains, and network activity. I check it weekly to:
- Verify blocklist sync is working (should complete daily)
- Review the top queried domains
- Whitelist any false positives (legitimate sites accidentally blocked)
- Monitor memory and CPU usage
To view live logs from both services:
docker-compose logs -f unbound
docker-compose logs -f pihole
If Unbound stops responding, restart it without affecting Pi-hole:
docker-compose restart unbound
Update both images monthly to patch security vulnerabilities:
docker-compose pull
docker-compose up -d
Performance Tuning for Larger Networks
If you're serving 50+ devices or running multiple homelabs, increase Unbound's thread count in unbound.conf:
server:
num-threads: 8
msg-cache-size: 200m
rrset-cache-size: 400m
And increase Pi-hole's Docker memory allocation in docker-compose.yml:
pihole:
mem_limit: 1G
memswap_limit: 1G
Monitor actual usage with:
docker stats pihole unbound
Wrapping Up
You now have a private, recursive DNS server that blocks ads at the source, validates DNSSEC responses locally, and keeps all query metadata on your network. This is the foundation of a truly private homelab.
Next steps: add local DNS records for each of your self-hosted services, configure conditional forwarding for split-horizon DNS if needed, and consider setting up UFW firewall rules to restrict DNS access to your internal networks only.
If you're planning to scale this beyond a single machine or need redundancy, look into running a second Pi-hole + Unbound pair on a separate host. Or, for those wanting a managed solution, RackNerd's affordable KVM VPS can host a production-grade DNS server with excellent uptime guarantees.
Discussion