Skip to content

Self-Hosted Recursive DNS with unbound

A recursive resolver answers DNS queries by walking the DNS tree itself — from the root servers down — instead of forwarding your queries to someone else's resolver (Google 8.8.8.8, Cloudflare 1.1.1.1, OpenDNS, your ISP). Running one locally is a small, high-leverage piece of self-hosting: it improves privacy, removes a third-party dependency, and — the reason this article exists — it is mandatory for a working mail server.

Why bother

  • Privacy. Your browsing/DNS history stops being handed to a large resolver operator on every lookup.
  • No third-party outage or rate-limit in your critical path. You query authoritative servers directly.
  • DNS blocklists actually work. This is the big one for mail — see below.
  • Caching. Repeat lookups are answered from local cache in microseconds.

The open-resolver trap (why mail servers break without this)

Anti-spam DNS blocklists — Spamhaus (ZEN/PBL/SBL) and URIBL — are free only for low-volume users querying from their own IP. They refuse queries that arrive via a large public/open resolver, because those resolvers aggregate enormous query volume. Instead of a real answer they return a sentinel:

  • Spamhaus → 127.255.255.254 with a TXT of "Error: open resolver; ..."
  • URIBL → 127.0.0.1 (permission/blocked)

SpamAssassin sees the sentinel, marks the rule ..._BLOCKED (e.g. RCVD_IN_ZEN_BLOCKED_OPENDNS, URIBL_BLOCKED), writes a dnsblock_* flag file, and stops querying that zone entirely. You silently lose the single strongest spam signal — typically 3–4 points per spam. The visible symptom is spam that Bayes is certain about (BAYES_99/BAYES_999, bayes=1.0) still landing in the inbox because the total score never crosses required_score.

If your mail host forwards DNS to a public resolver (directly in resolv.conf, or via a systemd-resolved that forwards to one), you have this problem. A local recursive resolver fixes it because the outbound DNSBL queries now originate from your mail server's own IP, within the free tier.

Diagnose it first

Query a Spamhaus test record. 2.0.0.127 is always "listed"; a broken (public) resolver returns the error sentinel, a working recursor returns real data:

dig +short 2.0.0.127.zen.spamhaus.org TXT
#   "Error: open resolver; ..."        → BROKEN (public resolver)
#   "Listed by XBL/SBL/PBL"            → WORKING (own recursion)

dig +short 2.0.0.127.zen.spamhaus.org
#   127.255.255.254                    → BROKEN
#   127.0.0.2 / .4 / .10 / .11         → WORKING

(No dig? dnf install -y bind-utils on Fedora, apt install -y dnsutils on Debian/Ubuntu.)

Install unbound

Fedora / RHEL:

dnf install -y unbound
systemctl enable --now unbound

Debian / Ubuntu:

apt install -y unbound
systemctl enable --now unbound

The shipped default config is a full recursive resolver listening on 127.0.0.1:53 with DNSSEC validation via the built-in root trust anchor. For a single host you do not need to change unbound.conf at all — the defaults are correct.

Point the system at unbound

Option A — systemd-resolved host (Fedora Cloud, modern Ubuntu)

systemd-resolved owns /etc/resolv.conf (a stub on 127.0.0.53). Don't fight it — tell it to forward everything to unbound. Add a drop-in:

# /etc/systemd/resolved.conf.d/unbound.conf
[Resolve]
DNS=127.0.0.1
Domains=~.
DNSSEC=no
DNSOverTLS=no
systemctl restart systemd-resolved
resolvectl status | grep 'DNS Servers'   # should show 127.0.0.1

DNSSEC=no here just avoids double validation — unbound already validates DNSSEC upstream. The stub on 127.0.0.53 stays in place (that's what resolv.conf and most software use); it now forwards to unbound, which does the real recursion. This coexists cleanly — the two listen on different loopback addresses.

Option B — no systemd-resolved

Point /etc/resolv.conf directly at unbound and stop anything from overwriting it:

# /etc/resolv.conf
nameserver 127.0.0.1
options edns0 trust-ad

Re-arm SpamAssassin after the switch

SA caches the "this DNSBL is blocked" decision in flag files and in the running daemon. After DNS is fixed, clear them and restart:

find / -name 'dnsblock_*' -delete 2>/dev/null
systemctl restart spamassassin

Re-run the dig test (via the stub, @127.0.0.53, since that's SA's path) and confirm you now get Listed by XBL/SBL/PBL. A synthetic spamassassin -t message from a listed test IP should now show RCVD_IN_PBL / RCVD_IN_ZEN scoring points instead of ..._BLOCKED.

Notes

  • SELinux (Fedora): the packaged unbound runs confined under unbound_t out of the box — no policy changes needed.
  • Pi-hole users: you can keep Pi-hole for filtering and set its upstream to a local unbound instead of a public resolver — same recursion benefit, plus ad-blocking. See Pi-hole DoH/DoT Bypass Defense.
  • Don't "fix" a resolver problem on a mail server by pointing it at 8.8.8.8 / 1.1.1.1 — that is the problem. A local recursor is the whole point.

See Also