Server

unbound as a Recursive Resolver

By Prabath Thalangama· October 24, 2026· 3 min read
#dns#unbound#dnssec

Introduction

A recursive resolver answers "what is the A record for example.com" for clients by walking the DNS hierarchy (or forwarding to another resolver). unbound is the go-to: small, audited, validating, and fast. Use it as a LAN resolver, a per-host cache, or the recursive half behind an authoritative BIND.

A LAN resolver config

# /etc/unbound/unbound.conf
server:
    interface: 10.0.0.53
    interface: 127.0.0.1
    port: 53
    access-control: 127.0.0.0/8 allow
    access-control: 10.0.0.0/8 allow
    access-control: 0.0.0.0/0 refuse           # default-deny for the world

    do-ip6: yes
    prefer-ip6: no
    hide-identity: yes
    hide-version: yes

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

    # cache / performance
    cache-min-ttl: 0
    cache-max-ttl: 86400
    prefetch: yes                # refresh popular entries before they expire
    prefetch-key: yes
    msg-cache-size: 128m
    rrset-cache-size: 256m
    num-threads: 4
    so-reuseport: yes

    # privacy / hardening
    qname-minimisation: yes
    aggressive-nsec: yes
    harden-glue: yes
    harden-dnssec-stripped: yes
    unwanted-reply-threshold: 10000000

    # local overrides
    local-zone: "corp.internal." static
    local-data: "gitlab.corp.internal. IN A 10.0.5.10"
    local-data: "10.0.5.10 IN PTR gitlab.corp.internal."

    # split-horizon: forward an internal zone to the internal authoritative server
forward-zone:
    name: "corp.internal."
    forward-addr: 10.0.5.2

remote-control:
    control-enable: yes

Full recursion vs forwarding

  • Full recursion (default, no forward-zone: name: ".") — unbound queries the roots and follows delegations itself. Maximum privacy (no third party sees all your queries), resilient. Slightly slower cold cache.
  • Forwarding — send everything to an upstream (1.1.1.1, 9.9.9.9), optionally over DoT:
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

Use forwarding when full recursion is blocked (some networks filter outbound 53 to non-resolvers), or when you want the upstream's filtering/malware blocking. Otherwise full recursion is the better default.

Local zones and RPZ

  • local-zone / local-data — hardcode records, block domains (local-zone: "ads.example." always_nxdomain), or override.
  • RPZ (Response Policy Zones) — load a blocklist as a zone (rpz: clause) for ad/malware blocking at scale, updatable via zone transfer.

Verification and troubleshooting

unbound-checkconf
unbound-control status
unbound-control stats_noreset | grep -E 'total.num.queries|hit|recursion.time'
unbound-control reload
unbound-control flush_zone example.com
unbound-control dump_cache | head

dig @10.0.0.53 example.com +dnssec        # ad flag = validated
dig @10.0.0.53 dnssec-failed.org          # should SERVFAIL (validation working)
  • SERVFAIL for everything — DNSSEC anchor missing/stale (unbound-anchor -a /var/lib/unbound/root.key), no outbound 53/853, or full recursion blocked (switch to forwarding). unbound-control stats num.query.tcp / unwanted.
  • REFUSED — the client's subnet isn't in access-control: ... allow.
  • Internal names not resolving — no local-data/forward-zone for the internal zone, or qname-minimisation interacting badly with a broken internal auth server (rare — try qname-minimisation-strict: no).
  • Stale record after a DNS change — TTL still in cache; unbound-control flush <name> or lower cache-max-ttl. prefetch refreshes popular ones but respects TTL.
  • Slow first lookups — full recursion cold cache; prefetch: yes and a warm cache fix steady state. Or forward for lower cold latency.
  • dnssec-failed.org resolves (should fail) — validation isn't actually on: missing/invalid trust anchor, or val-permissive-mode: yes set. Fix the anchor.
  • High memory — cache sizes; msg-cache-size + rrset-cache-size are the big ones. Size to your query diversity.
  • On a host with systemd-resolved — port 53 conflict. Disable systemd-resolved's stub (DNSStubListener=no) or bind unbound to a different address.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.