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:

SettingDefaultPurpose
do-ip4 / do-ip6yes / yesIPv4 and IPv6 support
do-tcp / do-udpyes / yesTCP and UDP listeners
harden-dnssec-strippedyesReject responses without expected DNSSEC data
qname-minimisationyesSend minimal query info to upstream servers
use-caps-for-idyesRandomize query capitalization (anti-spoofing)
prefetchyesRefresh popular cache entries before expiry
serve-expiredyesServe stale cache entries while refreshing
hide-identity / hide-versionyes / yesDon’t reveal server software in responses
Cache TTL300–86400sMinimum 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 optionally unbound.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

ResourceValue
RAM~30 MB idle, scales with cache (auto-sized to available memory)
CPUVery low — DNS resolution is not compute-intensive
Disk<10 MB for container and config
NetworkMinimal 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.

Comments