Unbound: Running Your Own Recursive DNS Resolver

Unbound: Running Your Own Recursive DNS Resolver

Your queries go to your provider’s resolver by default, which means they have a log of every domain you look up. Switching to a public resolver moves that log rather than removing it. Unbound resolves from the root servers itself, so nobody holds the whole picture.

What recursion means here

A forwarder takes your query, asks somebody else, and caches the answer. Fast, simple, and that somebody sees everything you ask.

A recursive resolver does the work:

  1. Ask a root server who handles .com
  2. Ask the .com servers who handles example.com
  3. Ask example.com’s nameservers for the record
  4. Cache each step

No single party sees your full query stream. The root servers see that somebody wanted .com, which they see from everybody.

The trade is latency on a cold cache. Our DNS explainer covers the hierarchy in more detail.

Install and a working config

sudo apt install unbound            # Debian and Ubuntu
sudo dnf install unbound            # Fedora
sudo pacman -S unbound              # Arch
sudo tee /etc/unbound/unbound.conf.d/local.conf <<'EOF'
server:
    # listen where you need it
    interface: 127.0.0.1
    interface: 10.0.0.5
    port: 5335

    # who may query
    access-control: 127.0.0.0/8 allow
    access-control: 10.0.0.0/24 allow
    access-control: 0.0.0.0/0 refuse

    # sensible hardening
    hide-identity: yes
    hide-version: yes
    harden-glue: yes
    harden-dnssec-stripped: yes
    harden-below-nxdomain: yes
    use-caps-for-id: no

    # DNSSEC
    auto-trust-anchor-file: "/var/lib/unbound/root.key"
    val-clean-additional: yes

    # do not leak private ranges upstream
    private-address: 10.0.0.0/8
    private-address: 172.16.0.0/12
    private-address: 192.168.0.0/16
    private-address: 169.254.0.0/16
    private-address: fd00::/8
    private-address: fe80::/10

    # cache and performance
    cache-min-ttl: 300
    cache-max-ttl: 86400
    prefetch: yes
    prefetch-key: yes
    msg-cache-size: 64m
    rrset-cache-size: 128m
    num-threads: 2

    # IPv6, off if your network has none
    do-ip6: no

    # privacy
    qname-minimisation: yes
    rrset-roundrobin: yes
EOF
sudo unbound-checkconf
sudo systemctl enable --now unbound

unbound-checkconf before every restart. Unbound refuses to start on a config error, and a resolver that will not start means nothing on the network resolves anything.

dig @127.0.0.1 -p 5335 example.com

The settings worth understanding

port: 5335 is deliberate, for running behind Pi-hole on the same host. If Unbound is your only resolver, use 53.

prefetch: yes is the one that makes it feel fast. Unbound refreshes popular records before they expire, so the common case is a cache hit even for entries whose TTL has lapsed. Without it, every popular domain is a cold lookup once per TTL.

qname-minimisation: yes sends only as much of the name as each server needs. The root servers learn you wanted .com, not that you wanted secret-project.example.com. It is a real privacy improvement and costs nothing.

cache-min-ttl: 300 overrides very short TTLs. Some CDNs set 30 seconds, which produces constant lookups. Setting a floor helps, and set it too high and you will be slow to follow a legitimate change during a migration. Five minutes is a reasonable compromise.

private-address stops a public domain resolving to an internal address, which is the DNS rebinding defence. Without it, a hostile domain can point at 192.168.1.1 and have your browser treat your router as same-origin.

access-control: 0.0.0.0/0 refuse is not optional. An open resolver is used for reflection attacks, which gets your server blocked and makes you part of somebody else’s problem.

DNSSEC

# the trust anchor, usually created by the package
sudo -u unbound unbound-anchor -a /var/lib/unbound/root.key
ls -l /var/lib/unbound/root.key
# should succeed, with the ad flag
dig @127.0.0.1 -p 5335 dnssec-failed.org +dnssec
# should return SERVFAIL, proving validation works
# the ad flag means validated
dig @127.0.0.1 -p 5335 cloudflare.com +dnssec | grep -E 'flags:|status:'
# flags: qr rd ra ad;

ad in the flags means the answer was validated. Its absence means either the zone is unsigned or validation did not happen, and those are different situations.

Be clear on what DNSSEC does: it authenticates answers, it does not encrypt them. Somebody watching your traffic still sees every domain you resolve. It defeats tampering and cache poisoning, not observation.

When a domain stops resolving after you enable validation, it is nearly always their broken zone rather than your resolver. Confirm before working around it:

# check against a known validating resolver
dig @1.1.1.1 problem-domain.com +dnssec

# only then, and reluctantly
# server:
#     domain-insecure: "problem-domain.com"

domain-insecure disables validation for that domain entirely, which is the protection you just turned on. Prefer telling the domain owner.

Behind Pi-hole

The pairing that gets recommended, and the reason is sound: Pi-hole decides which queries to answer, Unbound decides who resolves the rest.

clients → Pi-hole :53 → Unbound :5335 → root servers

In Pi-hole’s settings, set the upstream to 127.0.0.1#5335 and uncheck every public resolver.

# verify the chain
dig @127.0.0.1 -p 53 example.com      # through Pi-hole
dig @127.0.0.1 -p 5335 example.com    # Unbound directly

Without Unbound, Pi-hole blocks ads and forwards everything else to a public resolver, so a third party still has your query log. That is the gap this closes.

Serving a network

# in the Unbound config
interface: 0.0.0.0
port: 53
access-control: 192.168.1.0/24 allow
access-control: 0.0.0.0/0 refuse
sudo ufw allow from 192.168.1.0/24 to any port 53

On a systemd machine, systemd-resolved already holds port 53:

sudo systemctl disable --now systemd-resolved
sudo rm /etc/resolv.conf
echo 'nameserver 127.0.0.1' | sudo tee /etc/resolv.conf

Or leave it running and have it forward to Unbound, per our systemd-resolved guide. Two things fighting over port 53 is a confusing failure, and ss -ulnp | grep :53 identifies it immediately.

Local names

server:
    local-zone: "home.arpa." static
    local-data: "nas.home.arpa. IN A 192.168.1.10"
    local-data: "router.home.arpa. IN A 192.168.1.1"
    local-data-ptr: "192.168.1.10 nas.home.arpa"

Use home.arpa, which is reserved for exactly this. .local collides with mDNS, and inventing a TLD like .lan risks colliding with a real one later.

For names your DHCP server assigns, forward that zone to it rather than maintaining a static list:

forward-zone:
    name: "home.arpa."
    forward-addr: 192.168.1.1

Forwarding with encryption

If you would rather forward than recurse, at least encrypt the hop:

forward-zone:
    name: "."
    forward-tls-upstream: yes
    forward-addr: 1.1.1.1@853#cloudflare-dns.com
    forward-addr: 9.9.9.9@853#dns.quad9.net
server:
    tls-cert-bundle: /etc/ssl/certs/ca-certificates.crt

Be honest about what this achieves. DNS over TLS hides your queries from your network provider and shows them to whoever runs the forwarder. It moves the observer; it does not remove one.

Full recursion sends unencrypted queries to many authoritative servers, each seeing only its own slice. Neither is strictly better: one has a single trusted observer, the other has many partial ones. Pick according to which you mind more.

Monitoring

# enable the control socket
sudo unbound-control-setup
remote-control:
    control-enable: yes
    control-interface: 127.0.0.1
sudo unbound-control stats_noreset | grep -E 'total.num.queries|cachehits|recursion.time.avg'
sudo unbound-control status
sudo unbound-control dump_cache | wc -l

# useful operations
sudo unbound-control flush example.com
sudo unbound-control flush_zone example.com
sudo unbound-control reload

Cache hit rate is the number to watch. Above 80% on a settled resolver is normal, and below that suggests the cache is too small or TTLs are being ignored.

# hit rate, in one line
sudo unbound-control stats_noreset | awk -F= '
  /^total\.num\.cachehits/ {h=$2}
  /^total\.num\.queries/ {q=$2}
  END {printf "hit rate: %.1f%%\n", h/q*100}'

recursion.time.avg tells you how long a miss costs. A few hundred milliseconds is normal for a cold lookup requiring the full walk.

Debugging

# raise the log level temporarily
sudo unbound-control verbosity 3
sudo journalctl -fu unbound
sudo unbound-control verbosity 1

# trace a resolution end to end
dig +trace example.com

# is it listening where you think
sudo ss -ulnp | grep -E ':53|:5335'

Common causes, in order:

  1. Something else on port 53, usually systemd-resolved or dnsmasq
  2. access-control refusing the client, which returns REFUSED
  3. A missing or stale DNSSEC trust anchor, which SERVFAILs everything
  4. do-ip6: yes on a network with no working IPv6, which adds a timeout to every lookup
  5. The firewall, on the resolver or between it and the client

That fourth one is worth singling out. Partial IPv6, where the interface has an address but no route, makes every query wait for the IPv6 attempt to time out before falling back. It presents as “DNS is slow” with no errors anywhere, and do-ip6: no fixes it instantly. Our IPv6 guide covers testing whether yours works.

Frequently Asked Questions

What is the difference between a recursive resolver and a forwarder?

A forwarder passes your query to another resolver such as your provider or a public service and caches the answer. A recursive resolver walks the chain itself, from the root servers to the authoritative nameserver for the domain. The forwarder is simpler and faster to warm up, the recursive resolver means no third party sees your queries.

Does running Unbound make browsing faster?

Cached answers are as fast as anything can be, and a cache miss is slower than a large public resolver because you are doing the full walk yourself and they have everything cached already. The gain is privacy and independence rather than speed, and prefetching hides most of the difference in practice.

Do I need Unbound if I already run Pi-hole?

They do different jobs and pair well. Pi-hole decides which queries to answer at all, and by default forwards the rest to a public resolver. Putting Unbound behind it means the queries Pi-hole does allow are resolved by you rather than sent to a third party.

What does DNSSEC validation actually protect against?

It verifies that an answer came from the domain owner and was not modified in transit, which defeats cache poisoning and tampering by anything between you and the authoritative server. It does not encrypt anything, so queries remain visible, and it only helps for zones that are signed.

Why do some domains stop resolving after I enable DNSSEC?

Almost always a misconfigured zone on their side rather than a problem with yours, and a validating resolver refuses a broken signature rather than returning a possibly forged answer. Confirm with a validation checker before adding an exception, because an exception disables the protection for that domain.

Should I use DNS over TLS with Unbound?

Only if you are forwarding. Encrypting the hop to a forwarder hides your queries from your network provider and shows them to whoever runs the forwarder. Full recursion sends unencrypted queries to many authoritative servers instead, which spreads the exposure rather than removing it, and removes the single observer.