Self-Hosting Unbound as a Recursive DNS Resolver
What Is Unbound?
If you need a DNS resolver that talks directly to authoritative nameservers instead of trusting a third party, Unbound is the standard answer. Developed by NLnet Labs, it’s a validating, recursive, caching DNS resolver designed for security and performance. It supports DNSSEC validation out of the box and can forward queries over DNS-over-TLS (DoT) for encrypted upstream resolution.
Unbound replaces reliance on Google DNS (8.8.8.8), Cloudflare (1.1.1.1), or your ISP’s resolver. Instead of your DNS queries passing through a third party who can log them, Unbound resolves domains by walking the DNS hierarchy itself — root servers, TLD servers, authoritative servers — and caches the results locally.
Prerequisites
- A Linux server (Ubuntu 22.04+ recommended)
- Docker and Docker Compose installed (guide)
- 256 MB of free RAM (minimum — Unbound auto-scales cache to available memory)
- Port 53 available (not used by systemd-resolved or another DNS service)
- Basic understanding of DNS concepts (DNS Explained)
Free Up Port 53
Most Ubuntu systems run systemd-resolved on port 53. Disable it before deploying Unbound:
sudo systemctl disable systemd-resolved
sudo systemctl stop systemd-resolved
sudo rm /etc/resolv.conf
echo "nameserver 1.1.1.1" | sudo tee /etc/resolv.conf
Docker Compose Configuration
Create a directory for your Unbound deployment:
mkdir -p /opt/unbound && cd /opt/unbound
Create a docker-compose.yml file:
services:
unbound:
image: mvance/unbound:1.22.0
container_name: unbound
ports:
- "53:53/tcp"
- "53:53/udp"
volumes:
- ./config/forward-records.conf:/opt/unbound/etc/unbound/forward-records.conf:ro
- ./config/a-records.conf:/opt/unbound/etc/unbound/a-records.conf:ro
- ./config/srv-records.conf:/opt/unbound/etc/unbound/srv-records.conf:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "drill", "@127.0.0.1", "cloudflare.com"]
interval: 30s
timeout: 30s
start_period: 10s
retries: 3
Configuration Files
Create the config directory and files:
mkdir -p /opt/unbound/config
forward-records.conf — defines upstream DNS servers. This example uses Cloudflare over DNS-over-TLS:
cat > /opt/unbound/config/forward-records.conf << 'EOF'
forward-zone:
name: "."
forward-tls-upstream: yes
# Cloudflare DNS
forward-addr: 1.1.1.1@853#cloudflare-dns.com
forward-addr: 1.0.0.1@853#cloudflare-dns.com
EOF
To use Quad9 instead (malware-filtering DNS):
forward-zone:
name: "."
forward-tls-upstream: yes
forward-addr: 9.9.9.9@853#dns.quad9.net
forward-addr: 149.112.112.112@853#dns.quad9.net
a-records.conf — local DNS entries for your network:
cat > /opt/unbound/config/a-records.conf << 'EOF'
# Add local A records for your homelab
# local-data: "nas.home.lan. A 192.168.1.100"
# local-data: "proxmox.home.lan. A 192.168.1.50"
# local-data-ptr: "192.168.1.100 nas.home.lan."
# local-data-ptr: "192.168.1.50 proxmox.home.lan."
EOF
srv-records.conf — SRV records for service discovery (usually empty):
cat > /opt/unbound/config/srv-records.conf << 'EOF'
# SRV records (uncomment and modify as needed)
# _service._proto.name. TTL class SRV priority weight port target.
EOF
Start the stack:
docker compose up -d
Initial Setup
Test that Unbound is resolving queries:
# Query Unbound directly
dig @127.0.0.1 example.com
# Check DNSSEC validation
dig @127.0.0.1 sigfail.verteiltesysteme.net # Should return SERVFAIL
dig @127.0.0.1 sigok.verteiltesysteme.net # Should return NOERROR with A record
If both DNSSEC tests pass, Unbound is validating signatures correctly.
Point Your Network at Unbound
Update your router’s DHCP settings to distribute your server’s IP as the primary DNS server. Or configure individual machines:
# Linux: edit /etc/resolv.conf
nameserver 192.168.1.10 # Your server's IP
Configuration
The mvance/unbound image generates a hardened unbound.conf at startup with sensible defaults:
| Setting | Default | Purpose |
|---|---|---|
do-ip4 / do-ip6 | yes / yes | IPv4 and IPv6 support |
do-tcp / do-udp | yes / yes | TCP and UDP listeners |
harden-dnssec-stripped | yes | Reject responses without expected DNSSEC data |
qname-minimisation | yes | Send minimal query info to upstream servers |
use-caps-for-id | yes | Randomize query capitalization (anti-spoofing) |
prefetch | yes | Refresh popular cache entries before expiry |
serve-expired | yes | Serve stale cache entries while refreshing |
hide-identity / hide-version | yes / yes | Don’t reveal server software in responses |
| Cache TTL | 300–86400s | Minimum 5 minutes, maximum 24 hours |
Access Control
The default config allows queries from RFC1918 private ranges:
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
All other sources are denied by default.
Cache Sizing
Unbound auto-calculates cache size based on available memory:
- rrset-cache-size: 2/3 of available memory ÷ 3
- msg-cache-size: half of rrset-cache-size
- Threads: CPU count minus 1 (or 1 on single-core)
For a server with 2 GB RAM, expect roughly 400 MB allocated to DNS cache.
Advanced Configuration
Full Recursive Mode (No Forwarding)
For true recursive resolution without any upstream forwarder, replace forward-records.conf with an empty file and mount a custom unbound.conf:
cat > /opt/unbound/config/unbound.conf << 'EOF'
server:
interface: 0.0.0.0@53
do-ip4: yes
do-ip6: yes
do-tcp: yes
do-udp: yes
# Recursive mode — no forward-zone needed
root-hints: /opt/unbound/etc/unbound/var/root.hints
# DNSSEC
auto-trust-anchor-file: /opt/unbound/etc/unbound/var/root.key
# Access control
access-control: 127.0.0.1/32 allow
access-control: 10.0.0.0/8 allow
access-control: 172.16.0.0/12 allow
access-control: 192.168.0.0/16 allow
# Hardening
harden-glue: yes
harden-dnssec-stripped: yes
qname-minimisation: yes
hide-identity: yes
hide-version: yes
# Performance
prefetch: yes
serve-expired: yes
num-threads: 2
msg-cache-size: 64m
rrset-cache-size: 128m
include: /opt/unbound/etc/unbound/a-records.conf
include: /opt/unbound/etc/unbound/srv-records.conf
EOF
Then update your Docker Compose to mount the custom config:
volumes:
- ./config/unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro
- ./config/a-records.conf:/opt/unbound/etc/unbound/a-records.conf:ro
- ./config/srv-records.conf:/opt/unbound/etc/unbound/srv-records.conf:ro
Pairing With Pi-hole or AdGuard Home
Unbound works well as the upstream resolver for Pi-hole or AdGuard Home. The ad blocker handles filtering, Unbound handles resolution:
Client → Pi-hole (ad blocking) → Unbound (recursive DNS) → Root servers
In Pi-hole’s settings, set the upstream DNS to your Unbound container’s IP (e.g., 172.20.0.2#53 on a shared Docker network). See Network-Wide Ad Blocking for the full setup.
Reverse Proxy
Unbound serves DNS on port 53, not HTTP — a reverse proxy is not applicable. For remote DNS access, use a VPN like Tailscale or WireGuard to route DNS queries through your server. See Remote Access for options.
Backup
Unbound’s state is minimal:
- Configuration files:
forward-records.conf,a-records.conf,srv-records.conf, and optionallyunbound.conf - DNSSEC trust anchor:
/opt/unbound/etc/unbound/var/root.key(auto-fetched at startup — no backup needed) - Cache: Rebuilt automatically after restart — no backup needed
Back up only the config directory:
tar czf unbound-backup-$(date +%F).tar.gz /opt/unbound/config/
See Backup Strategy for a comprehensive approach.
Troubleshooting
Port 53 Already in Use
Symptom: Container fails to start with bind: address already in use
Fix: Disable systemd-resolved:
sudo systemctl disable --now systemd-resolved
sudo rm /etc/resolv.conf
echo "nameserver 1.1.1.1" | sudo tee /etc/resolv.conf
DNSSEC Validation Failures
Symptom: Legitimate domains return SERVFAIL
Fix: The DNSSEC root trust anchor may be outdated. Restart the container to re-fetch it:
docker compose restart unbound
Slow Initial Queries
Symptom: First query for a domain takes 200-500ms
Fix: This is normal for recursive resolution — Unbound walks the DNS hierarchy on first lookup. Subsequent queries hit the cache (sub-1ms). Enable prefetch: yes (default) to refresh popular entries before they expire.
No Response to Queries
Symptom: dig @127.0.0.1 example.com times out
Fix: Check container logs and verify port mapping:
docker compose logs unbound
ss -tlnp | grep 53
Ensure no firewall rules block port 53.
Resource Requirements
| Resource | Value |
|---|---|
| RAM | ~30 MB idle, scales with cache (auto-sized to available memory) |
| CPU | Very low — DNS resolution is not compute-intensive |
| Disk | <10 MB for container and config |
| Network | Minimal bandwidth; outbound to root/authoritative DNS servers required |
Verdict
If you want full control over your DNS resolution chain, Unbound is the right tool. It’s the most widely deployed recursive resolver for self-hosting, and the mvance/unbound Docker image makes deployment trivial — sensible defaults, auto-tuned cache, DNSSEC out of the box. Pair it with Pi-hole or AdGuard Home for ad blocking, and you’ve eliminated your dependency on third-party DNS entirely.
For users who just want DNS-level ad blocking without managing a separate resolver, Pi-hole or AdGuard Home with upstream forwarders (Cloudflare, Quad9) is simpler. Unbound is for when you want to cut the third party out of the loop completely.
FAQ
Is Unbound the same as Pi-hole?
No. Pi-hole is a DNS sinkhole for ad blocking — it filters queries. Unbound is a recursive resolver — it answers queries by walking the DNS hierarchy. They complement each other: Pi-hole handles filtering, Unbound handles resolution. See Pi-hole vs AdGuard Home for ad blocking comparisons.
Should I use forwarding mode or recursive mode?
Forwarding mode (default) sends queries to an upstream server like Cloudflare over encrypted DNS-over-TLS. It’s faster and simpler. Recursive mode talks directly to root servers — maximum privacy, but slightly slower for uncached queries. Most users should start with forwarding mode.
Does Unbound block ads?
No. Unbound is a resolver, not a filter. For ad blocking, deploy Pi-hole or AdGuard Home in front of Unbound.
Related
Get self-hosting tips in your inbox
Get the Docker Compose configs, hardware picks, and setup shortcuts we don't put in articles. Weekly. No spam.
Comments