Back to guides
Runbook

Build a Validating, Hardened Resolver

The most thorough answer to “which resolver do you trust?” is to become your own. This runbook walks the shape of a resolver that validates DNSSEC, encrypts its upstream, and is hardened against the usual tricks, with the directives that actually matter and how to prove each one works.

encrypted Your devices Unbound validate + cache DNSSEC ✓ dnscrypt-proxy encrypt upstream Upstreams public resolvers

Two small services: Unbound validates and caches, dnscrypt-proxy encrypts the hop to the outside world. Ports and addresses below are examples, choose your own.

Running your own resolver is not about speed; a big public resolver will usually be faster. It is about control: you decide what validates, what gets logged (nothing), and who sees your queries. If that trade appeals, here is the shape of it.

Why run your own

Whatever resolver you use sees the name of every site you visit, as the guide on what a DNS resolver is lays out. Picking a public one means trusting its operator with that list. Running your own removes that party entirely: the machine that sees your queries is yours, it keeps no logs unless you ask it to, and you can make it validate DNSSEC so forged answers are rejected before they reach you.

The cost is that you maintain it, and that a self-hosted resolver is easier to fingerprint than blending into a large shared one. It is a deliberate trade, sensible for a home network or a small server, not something most people need.

The shape: two small services

A good setup splits the work in two. Unbound is the resolver proper: it validates DNSSEC, caches answers, and applies the hardening. dnscrypt-proxy sits behind it and encrypts the final hop to the public upstreams, so even your own internet provider cannot read the lookups leaving your network. Unbound forwards everything to it.

There are two valid designs, and the difference is one of trust:

  • Pure recursion. Unbound talks directly to the authoritative servers itself, trusting no upstream at all. The most independent option, but those final queries leave on plain port 53, visible to your provider.
  • Forward to an encrypting proxy. Unbound forwards to dnscrypt-proxy, which encrypts to the upstreams. Your provider sees nothing, at the cost of trusting the chosen upstreams. This is the setup below.

One rule ties the whole thing together: both services bind to 127.0.0.1, localhost only, so neither is reachable from the internet. The only ports you expose are the encrypted front-end listeners (the standard DoT, DoH, and plain-DNS ports); the validating and encrypting pieces stay private on the machine, where nothing outside can reach them directly.

Unbound: validate and harden

This is the configuration this service actually runs, trimmed only of a few site-specific lines. It is tuned hard for a busy public resolver, but every block below earns its place. The comments explain what each one is for. The binary here is a custom build, Unbound 1.25 compiled against BoringSSL with link-time optimization. That is not arbitrary: under signed-cache-miss load most of a validating resolver's CPU goes to ECDSA signature checking, and moving off OpenSSL's provider dispatch onto BoringSSL's field arithmetic measurably cut that hot path (the full before-and-after is written up in the build spec). It is an optimization, not a requirement, a stock distribution package reads this same config identically.

unbound.conf (production, section comments added)
server:
    # ── Network: localhost only, IPv4 (front-end faces the world, not this) ──
    interface: 127.0.0.1
    port: 5353
    do-ip4: yes
    do-ip6: no
    do-udp: yes
    do-tcp: yes

    # ── DNSSEC: validate every answer against the root trust anchor ──
    module-config: "validator iterator"
    auto-trust-anchor-file: "/var/lib/unbound/root.key"
    root-hints: "/var/lib/unbound/root.hints"

    # Zones intentionally left unsigned (reverse-lookup zones often have
    # broken or missing DNSSEC); add your own unsigned test zones here too
    domain-insecure: "arin.net."
    domain-insecure: "ripe.net."

    # ── Swallow local/CPE namespaces so they never leak upstream ──
    local-zone: "local." always_nxdomain
    local-zone: "lan."   always_nxdomain
    local-zone: "home."  always_nxdomain
    local-zone: "corp."  always_nxdomain

    # ── Extended DNS Errors (RFC 8914): clearer failure reasons ──
    ede: yes
    ede-serve-expired: yes

    # ── Hardening: the teeth behind validation ──
    harden-glue: yes
    harden-large-queries: yes
    harden-dnssec-stripped: yes
    harden-algo-downgrade: yes
    harden-short-bufsize: yes
    hide-identity: yes
    hide-version: yes
    deny-any: yes
    qname-minimisation: yes          # tell each level only what it needs

    # ── EDNS hygiene: 1232-byte buffer avoids IP fragmentation ──
    edns-buffer-size: 1232
    edns-tcp-keepalive: yes
    edns-tcp-keepalive-timeout: 15000
    rrset-roundrobin: yes

    # ── TTL floors and ceilings ──
    cache-min-ttl: 300
    cache-max-ttl: 86400
    cache-min-negative-ttl: 900
    neg-cache-size: 64m

    # ── Serve-expired: answer instantly from a stale record, refresh behind ──
    serve-expired: yes
    serve-expired-ttl: 86400
    serve-expired-client-timeout: 500
    serve-expired-reply-ttl: 30
    serve-expired-ttl-reset: yes

    # ── Performance ──
    prefetch: yes
    prefetch-key: yes
    minimal-responses: yes
    aggressive-nsec: yes
    num-threads: 4

    # Cache + slabs = 2x threads to cut lock contention under load
    msg-cache-size: 256m
    rrset-cache-size: 512m
    msg-cache-slabs: 8
    rrset-cache-slabs: 8
    infra-cache-slabs: 8
    key-cache-slabs: 8

    # ── Connection budgets ──
    outgoing-range: 8192
    num-queries-per-thread: 4096
    incoming-num-tcp: 1024
    outgoing-num-tcp: 2048
    tcp-idle-timeout: 30000
    sock-queue-timeout: 3            # drop UDP queued >3s

    # ── Kernel socket buffers (match your sysctl rmem/wmem max) ──
    so-reuseport: yes
    so-rcvbuf: 8m
    so-sndbuf: 8m

    # ── Anti-amplification: cap per-domain query rate ──
    ratelimit: 100
    ratelimit-factor: 10
    ratelimit-backoff: yes

    # ── Anti-spoofing: DNS cookies + spoofed-reply circuit breaker ──
    answer-cookie: yes
    unwanted-reply-threshold: 1000
    max-udp-size: 1232              # force TCP for bigger replies

    # ── Access control + rebinding protection ──
    access-control: 127.0.0.0/8 allow   # add your front-end host as needed
    private-address: 192.168.0.0/16
    private-address: 169.254.0.0/16
    private-address: 172.16.0.0/12
    private-address: 10.0.0.0/8

    tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt"

# ── Hand everything to the local encrypting proxy ──
forward-zone:
    name: "."
    forward-addr: 127.0.0.1@5053   # dnscrypt-proxy

The block that makes it a validating resolver is small: module-config: validator iterator plus the root anchor. Everything in the hardening section is what stops an attacker from quietly stripping that validation away, and qname-minimisation keeps each server in the chain from learning more than its one step needs.

The rest is tuning for a resolver under real load. serve-expired hands back a slightly stale answer in under half a second while refreshing it in the background, so a slow upstream never stalls you. The cache slabs are set to twice the thread count so concurrent lookups contend for fewer locks. And ratelimit, DNS cookies, and the spoofed-reply threshold are defense-in-depth that sit beside DNSSEC rather than replacing it.

dnscrypt-proxy: encrypt the upstream

Unbound forwards to dnscrypt-proxy, which speaks DNSCrypt or DoH to the public resolvers so the final hop is never in the clear. Here it is kept deliberately thin, a pure encrypting relay in front of Unbound's cache and validation. The binary is a trimmed edge fork: it loads its resolver lists from local signed cache only with no auto-update or outbound refresh (which is why the upstreams are pinned by stamp below), pools per-query buffers to cut allocations, and drops the monitoring UI and ODoH to shrink the attack surface. Stock dnscrypt-proxy works the same for this config; the fork is just leaner.

dnscrypt-proxy.toml (production, section comments added)
listen_addresses = ['127.0.0.1:5053']

# Unbound validates DNSSEC downstream, so do not require it again here
require_dnssec = false
require_nolog  = false
require_nofilter = true              # never use a filtering/redirecting upstream

# Upstreams to exclude from selection
disabled_server_names = ['adguard-dns', 'yandex', 'cisco', 'nextdns']

max_clients = 2048
keepalive = 120
cert_refresh_delay = 240

ipv6_servers = false
block_ipv6 = false

cache = false                        # Unbound is the cache; do not double-cache
timeout = 800
netprobe_timeout = 1000

dnscrypt_servers = true
doh_servers = true
# http3 = true                       # experimental: added latency in testing

lb_strategy = 'wp2'                  # load-balance across the two fastest
lb_estimator = false

# The upstreams actually used
server_names = ['cloudflare', 'quad9-dnscrypt-ip4-nofilter-pri', 'google']

block_unqualified = true
block_undelegated = true

# Pin each upstream by static stamp so resolution never depends on a live
# catalog fetch; stamps come from the signed public-resolvers list
[static]
  [static.'cloudflare']
  stamp = 'sdns://AgcAAAAAAAAABzEuMC4wLjEA...'
  [static.'quad9-dnscrypt-ip4-nofilter-pri']
  stamp = 'sdns://AQYAAAAAAAAADTkuOS45LjEwOjg0NDM...'
  [static.'google']
  stamp = 'sdns://AgUAAAAAAAAABzguOC44LjgA...'

Two choices are worth calling out. require_dnssec = false looks wrong on a security-focused box, but it is correct here: Unbound already validates every answer downstream, so requiring it again would only re-check finished work. cache = false is the same logic, Unbound holds the cache, so the proxy stays a thin encrypting hop with nothing to duplicate.

The upstreams are pinned by static stamp rather than chosen from a live list. Each stamp encodes the resolver's address and public key, taken from the signed public-resolvers catalog, so startup never depends on fetching anything. This setup balances across Cloudflare, Quad9, and Google; if source anonymity matters more than simplicity, this is where the anonymized relays from the protocol comparison would slot in instead.

Or drop the proxy entirely

The two pieces are a switch, not a rebuild. You can remove dnscrypt-proxy and let Unbound recurse straight to the authoritative servers, trusting no upstream at all. You give up the encrypted upstream hop, and you should expect slower answers for the first few days while the cache warms from empty, but you gain full independence: no third party resolver in the path, ever. It is a reasonable thing to measure for yourself, and the configuration above already contains everything Unbound needs to do it once the forward-zone is removed.

Prove each part works

Do not assume; check. Two commands confirm DNSSEC is really validating, point them at your resolver's address and port:

verifying DNSSEC
# A good signed name returns the "ad" (authenticated data) flag
dig +dnssec @127.0.0.1 -p 5335 example.com | grep flags

# A deliberately broken name MUST fail, not resolve
dig @127.0.0.1 -p 5335 dnssec-failed.org    # expect SERVFAIL

If the first shows ad in the flags and the second returns SERVFAIL, validation is live: it accepts good signatures and rejects bad ones. For the encryption half, point a device at your resolver and run the DNS leak test; only your own resolver's upstream path should appear, and how the leak test works explains why that result is trustworthy.

Is this for you?

If you enjoy running infrastructure and want the resolver in your queries to be one you control end to end, this is a rewarding setup, and it is close to how this service itself is built; the infrastructure page shows the production shape.

If you would rather not maintain a service, you lose very little by pointing your device at a validating, encrypted resolver instead. The setup guide covers that in a few clicks per platform, and you still get DNSSEC validation and an encrypted path without running anything yourself.

Confirm it before you trust it

However you resolve DNS, the test tells you which server actually answered.

Run the DNS leak test