Introduction
Every time you go online, your device sends DNS queries to dozens of third-party domains — advertisers, analytics platforms, and trackers silently collect your browsing data in the background. Worse, your ISP’s default DNS servers often log your entire browsing history, and some even inject ad pages into your traffic.
Self-hosting your DNS is the ultimate solution to these problems. Pi-hole is the most popular network-wide ad blocker, and Unbound is a high-performance recursive DNS resolver. Combining them on a VPS gives you:
- 🚫 Ad & tracker blocking — block ads across every device on your network
- 🔒 DNS privacy — recursive resolution with zero dependence on third parties
- ⚡ Faster DNS responses — local caching + optimized recursive queries
- 💰 Bandwidth cost savings — reduced ad traffic means lower data usage
This guide will walk you through deploying Pi-hole + Unbound with Docker on a VPS, creating your own private DNS gateway.
Why Pi-hole + Unbound?
Pi-hole: Network-Level Ad Blocker
Pi-hole works at the DNS level using domain blacklists. When a device on your network requests a domain, Pi-hole checks if it’s on a blocklist — if so, it returns a null response (or a local page), preventing the device from ever reaching the ad server.
Pi-hole’s unique advantages:
- Cross-platform — every device that uses DNS is supported (phones, laptops, smart TVs, IoT)
- No client installation — just point your DNS to Pi-hole’s IP
- Rich blocklists — hundreds of thousands of ad/tracker domains curated by the community
- Visual analytics — built-in web dashboard shows block stats and query distribution
Unbound: Recursive DNS Resolver
Most home routers use upstream DNS (like 8.8.8.8 or Cloudflare 1.1.1.1), meaning your DNS queries are logged by third parties. Unbound works differently — it performs recursive resolution starting from the root DNS servers:
Device queries example.com
→ Unbound asks root server: "Who's authoritative for .com?"
→ Root returns .com's NS records
→ Unbound asks .com server: "Who's authoritative for example.com?"
→ .com returns example.com's NS records
→ Unbound asks example.com's authoritative server: "What's the IP?"
→ Returns the result and caches it
Result: No middleman knows what domains you’re querying. Your DNS lookups are completely private.
The Combined Advantage
| Component | Role | Cost |
|---|---|---|
| Pi-hole | Ad blocking + local cache + Web UI | Free (open-source) |
| Unbound | Recursive resolution + DNSSEC + privacy | Free (open-source) |
| VPS | 24/7 operation | ~$3-6/month |
A $5/month budget VPS can easily handle a family’s DNS query load while running other lightweight services alongside it.
Prerequisites
- A VPS (recommended: 1GB RAM, 10GB disk, any Linux distro)
- Docker and Docker Compose installed
- A domain name (optional, for accessing the Pi-hole web interface)
Install Docker & Docker Compose
Not installed yet? One-liner:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Log out and back in, or run: newgrp docker
Step 1: Configure Docker Compose
Create a project directory and docker-compose.yml:
mkdir -p ~/pihole-unbound
cd ~/pihole-unbound
nano docker-compose.yml
Paste the following:
version: "3.8"
services:
unbound:
image: mvance/unbound:latest
container_name: unbound
restart: unless-stopped
ports:
- "5353:53/udp"
- "5353:53/tcp"
volumes:
- ./unbound:/opt/unbound/etc/unbound/
cap_add:
- NET_ADMIN
- SETUID
- SETGID
healthcheck:
test: ["CMD", "dig", "@127.0.0.1", "-p", "5353", "google.com"]
interval: 30s
timeout: 10s
retries: 3
pihole:
image: pihole/pihole:latest
container_name: pihole
restart: unless-stopped
ports:
- "53:53/udp"
- "53:53/tcp"
- "8080:80/tcp" # Web admin interface
environment:
TZ: "UTC"
WEBPASSWORD: "your-admin-password-here" # 🔑 Change to a strong password
PIHOLE_DNS_: "127.0.0.1#5353" # Upstream DNS → Unbound
DNSSEC: "true" # Enable DNSSEC
CONDITIONAL_FORWARDING: "false"
volumes:
- ./pihole/etc-pihole:/etc/pihole
- ./pihole/etc-dnsmasq.d:/etc/dnsmasq.d
depends_on:
unbound:
condition: service_healthy
cap_add:
- NET_ADMIN
- NET_RAW
- NET_BIND_SERVICE
Key configuration notes:
PIHOLE_DNS_set to127.0.0.1#5353— Pi-hole forwards queries to your local Unbound instanceDNSSEC: "true"— enables DNS Security Extensions to prevent spoofingdepends_onensures Unbound is healthy before Pi-hole starts- Host port 53 maps to Pi-hole, Pi-hole forwards to Unbound on port 5353
Step 2: Configure Unbound
Create the configuration directory and file:
mkdir -p ~/pihole-unbound/unbound
Create ~/pihole-unbound/unbound/unbound.conf:
server:
# Basic settings
verbosity: 1
interface: 0.0.0.0
port: 5353
do-ip4: yes
do-ip6: yes
do-udp: yes
do-tcp: yes
# Privacy & security hardening
do-not-query-localhost: no
hide-identity: yes
hide-version: yes
harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: yes
qname-minimisation: yes
# Cache tuning
cache-min-ttl: 3600
cache-max-ttl: 86400
prefetch: yes
prefetch-key: yes
num-threads: 2
msg-cache-slabs: 8
rrset-cache-slabs: 8
infra-cache-slabs: 8
key-cache-slabs: 8
rrset-cache-size: 256m
msg-cache-size: 128m
so-rcvbuf: 1m
so-sndbuf: 1m
# DNSSEC
auto-trust-anchor-file: "/opt/unbound/etc/unbound/root.key"
val-clean-additional: yes
val-permissive-mode: no
val-log-level: 2
# Access control (Docker network + localhost only)
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
# Private addresses (don't query externally)
private-address: 192.168.0.0/16
private-address: 172.16.0.0/12
private-address: 10.0.0.0/8
# Root hints
root-hints: "/opt/unbound/etc/unbound/root.hints"
Download Root Hints
Unbound needs to know where the root DNS servers are:
curl -o ~/pihole-unbound/unbound/root.hints https://www.internic.net/domain/named.cache
Step 3: Start the Services
cd ~/pihole-unbound
docker compose up -d
Check that both containers are running:
docker compose ps
Both should show Up. Check logs for errors:
docker compose logs pihole | tail -20
docker compose logs unbound | tail -20
Step 4: Verify the DNS Resolution Chain
Test Unbound Directly
# Normal resolution test
dig @127.0.0.1 -p 5353 google.com
# DNSSEC validation
dig @127.0.0.1 -p 5353 sigfail.verteiltesysteme.net # Should return SERVFAIL
dig @127.0.0.1 -p 5353 sigok.verteiltesysteme.net # Should return NOERROR
Test Pi-hole
# Query through Pi-hole
dig @127.0.0.1 -p 53 google.com
# Verify ad domain blocking
dig @127.0.0.1 -p 53 doubleclick.net
# Should return 0.0.0.0 or 127.0.0.1
Access the Pi-hole Web Dashboard
Open your browser and navigate to http://YOUR_VPS_IP:8080/admin/. Log in with the password you set in WEBPASSWORD.
The dashboard shows:
- Total queries today — DNS requests processed
- Queries blocked — percentage of ad/tracker queries blocked
- Top domains — most frequently queried domains
- Top clients — devices making queries
Step 5: Point Your Devices to Pi-hole
With Pi-hole running, you need to configure your devices to use it as their DNS server.
Option A: Router-Level Configuration (Recommended)
Set your VPS IP as the primary DNS server in your router’s DHCP settings. Every device on your network will automatically use Pi-hole.
Pros: Zero configuration, all devices protected automatically Cons: If the VPS goes offline, DNS fails (configure a fallback)
Option B: Manual Device Configuration
| Device | DNS Settings Path |
|---|---|
| Windows | Network Settings → Edit IP Settings → Manual DNS |
| macOS | System Preferences → Network → Advanced → DNS |
| iOS | Settings → Wi-Fi → Configure DNS |
| Android | Wi-Fi Settings → Advanced → IP Settings → Static |
| Linux | Edit /etc/resolv.conf or configure via Netplan |
Option C: Tailscale/ZeroTier
If you use Tailscale for network management, push DNS to all nodes:
# Set DNS via Tailscale
tailscale set --accept-dns=true
Advanced Optimization
1. Add More Adlists
Pi-hole ships with default blocklists, but you can add more. In the Web UI, go to Group Management → Adlists and add:
https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
https://someonewhocares.org/hosts/zero/hosts
https://raw.githubusercontent.com/PolishFiltersTeam/KADhosts/master/KADhosts.txt
2. Whitelist Important Domains
Some sites break due to DNS blocking. Add these to Domain Management → Whitelist:
googleadservices.com— Google servicesocsp.digicert.com— SSL certificate validation- Windows/macOS update domains
3. Tune Cache Performance
The Unbound configuration above is already optimized. For a 1GB VPS, rrset-cache-size: 256m and msg-cache-size: 128m work well. Increase to 512m/256m if you have more RAM.
4. Enable Query Logging
For debugging:
docker compose exec pihole pihole logging on
# Watch live queries
docker compose logs -f pihole | grep "query\|blocked"
5. Secondary DNS Failover
Configure two DNS addresses on your router:
- Primary DNS: Your VPS IP (Pi-hole)
- Fallback DNS:
1.1.1.1or8.8.8.8
This ensures devices can still resolve domains during VPS maintenance.
Security Considerations
🔒 Firewall Configuration
Never expose ports 53/5353 to the public internet — this turns your VPS into an open DNS resolver that can be abused for DDoS amplification attacks.
# Allow only internal network access
ufw allow from 192.168.0.0/16 to any port 53
ufw allow from 10.0.0.0/8 to any port 53
ufw deny 53
# If using a cloud provider firewall, also restrict source IPs there
🔑 Change Default Password
Always set a strong password for the Pi-hole admin interface:
docker compose exec pihole pihole -a -p
📦 Regular Updates
cd ~/pihole-unbound
docker compose pull
docker compose up -d
# Update blocklists: Web UI → Tools → Update Gravity
Performance Comparison
Real-world metrics from a 5-person household with 12 devices:
| Metric | Before (ISP DNS) | After (Pi-hole + Unbound) | Improvement |
|---|---|---|---|
| Daily DNS queries | ~28,000 | ~25,000 | -10.7% |
| Daily blocked queries | — | ~4,500 | 16% blocked |
| Average DNS response | 28ms | 2ms (cached) / 18ms (recursive) | Faster |
| Page load speed | Baseline | 15-30% faster | Significant |
| Monthly bandwidth saved | — | ~1.5GB | Ad traffic eliminated |
Typical scenario: A household has 4,500 ad/tracker queries blocked daily — meaning at least 16% of your traffic was “junk.” With Pi-hole, not only do pages load faster, your mobile data plan lasts noticeably longer.
Troubleshooting
Queries Timing Out
# Are containers running?
docker compose ps
# Is Unbound listening?
netstat -tulpn | grep 5353
# Restart services
docker compose restart
Pi-hole Dashboard Shows 0 Queries
DNS requests aren’t reaching Pi-hole. Check:
- Are devices configured to use your VPS IP for DNS?
- Is the VPS firewall allowing port 53 (internal only)?
- Is your router forcing its own DNS?
DNSSEC Validation Failures
Some domains have incomplete DNSSEC configuration. If a specific site is unreachable, temporarily set val-permissive-mode: yes in the Unbound config to debug.
Summary
Deploying Pi-hole + Unbound with Docker on a VPS is one of the highest-ROI self-hosting projects you can do:
- ✅ Block ads & trackers — a clean, fast browsing experience
- ✅ DNS privacy — recursive resolution, zero third-party dependency
- ✅ Faster page loads — caching + eliminated ad requests
- ✅ Minimal resource usage — a tiny VPS handles it easily
- ✅ Low maintenance — Docker containers, one-command updates
A $5/month VPS can deliver enterprise-grade DNS protection for your entire household — and still have room to run other lightweight services like Uptime Kuma, Nginx Proxy Manager, or a VPN server.
Next steps to explore: Combine Pi-hole + WireGuard to extend ad-blocking protection to mobile devices outside your home network, or use Pi-hole’s built-in DHCP server to replace your router’s DHCP entirely.
Found this guide helpful? Share it with someone who’d love a cleaner, faster, and more private internet!
