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.
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.
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:
# 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