Building a Modern, Privacy-First Home DNS with Unbound
Over several months, we evolved from a simple DNS forwarder into a sophisticated, public-facing Unbound DNS setup with DoT/DoH, dnscrypt-proxy integration, EDNS tuning, and OISD blocklists. This post distills that journey into a practical guide: not just how to configure Unbound, but why we made each choice and what actually mattered.
Why Unbound?
We needed a fast, privacy-respecting, locally-controlled DNS resolver on FreeBSD/Ubuntu that could serve Android Private DNS clients and integrate with privacy providers like AdGuard and NextDNS.
After testing dnscrypt-proxy + HAProxy and Caddy + Unbound, we found Unbound as the core was the best fit because it:
- Acts as a validating resolver, forwarder, and cache in one.
- Supports native DoT/DoH when compiled properly.
- Integrates seamlessly with dnscrypt-proxy for upstream privacy.
Getting Basics Right: Forwarding Only
Since we forward to privacy providers rather than run full recursion, we simplified Unbound substantially:
forward-zone:
name: "."
forward-first: no
forward-addr: 127.0.0.1@5300 # e.g., dnscrypt-proxy
Key behaviors:
name: "."forwards everything.forward-first: nomeans pure forwarding—no recursive fallback on upstream failure.- With multiple
forward-addrentries, Unbound tracks performance and picks the best upstream.[20]
Upstream Choices: Plain DNS vs DoT vs DoH
Plain DNS Forwarding
When dnscrypt-proxy only accepted plain DNS:
server:
do-udp: yes
do-tcp: yes
tcp-upstream: no
forward-zone:
name: "."
forward-tls-upstream: no
forward-addr: 127.0.0.1@5300
DNS-over-TLS Upstream
For resolvers like Quad9 or NextDNS over DoT:
forward-zone:
name: "."
forward-tls-upstream: yes
forward-addr: quad9.net@853
DNS-over-HTTPS via dnscrypt-proxy
We let dnscrypt-proxy speak DoH to AdGuard/NextDNS and Unbound send plain DNS to dnscrypt-proxy. This separation simplified debugging.
Serving DoT for Android Private DNS
One central goal: "I want any Android Private DNS client to connect."
server:
interface: 0.0.0.0@853
interface: ::0@853
tls-port: 853
tls-service-key: "/etc/letsencrypt/live/dot.example.com/privkey.pem"
tls-service-pem: "/etc/letsencrypt/live/dot.example.com/fullchain.pem"
access-control: 0.0.0.0/0 allow
access-control: ::0/0 allow
Critical details:
- The certificate CN/SAN must match the hostname clients use.
- Let's Encrypt privkey/fullchain work directly if file permissions are correct.
- Test with:
dig @your.hostname -p 853 +tls example.com
Serving DoH (Optional)
To serve DoH over IPv6 on port 443, Unbound must be compiled with nghttp2:
server:
interface: [::0]@443
tls-service-key: "/path/to/privkey.pem"
tls-service-pem: "/path/to/cert.pem"
https-port: 443
Test with: curl -X POST -d @query.bin https://dot.example.com/dns-query --http2
Privacy & Performance: EDNS and ECS
EDNS(0) Basics
Unbound supports EDNS(0) by default—there is no single "enable EDNS" toggle.
EDNS Client Subnet (ECS)
To forward client subnet info to upstreams (e.g., Quad9) for better CDN geolocation:
- Compile Unbound with
--enable-subnet. - Enable in config:
send-client-subnet: yes. - Optional:
client-subnet-always-forward: yesto forward even if client didn't request it.
Note: ECS only forwards a truncated subnet (e.g., /24), not the full client IP. Raw client IP forwarding is not possible in DNS standards.
Hardening: Access Control & Rate Limiting
Broad Access Control for Public DoT
access-control: 0.0.0.0/0 allow
access-control: ::0/0 allow
But this requires abuse protection.
Rate Limiting
On Unbound 1.23+, use:
ratelimit-queries: 100 # per-IP queries/second
ratelimit-answers: 100
ratelimit-log: yes
On Unbound 1.19.x, use iptables/nftables instead or upgrade.
Logging Best Practices
Keep normal operation quiet:
server:
logfile: "/var/log/unbound.log"
verbosity: 1
Use unbound -d -v for temporary debugging only.
Tuning for Low-Traffic, Quick Response
Threads & File Descriptors
For light load with emphasis on latency:
outgoing-range: 512
num-queries-per-thread: 256
num-threads: 1
(Default: outgoing-range: 4096, num-queries-per-thread: 2048.) Keep the ratio: num-queries-per-thread ≈ outgoing-range / 2.
Cache Tuning
msg-cache-size: 8m
rrset-cache-size: 16m
cache-min-ttl: 600 # artificially extend TTLs
cache-max-ttl: 86400
This keeps small servers responsive while reducing upstream queries.
DNS-Level Content Filtering: OISD Blocklist
Integrating OISD
-
Download the Unbound-format OISD list:
curl -o /etc/unbound/blocklist.conf \ "https://oisd.nl/api/domainlist/unbound?list=basic" -
Include in
unbound.conf:include: "/etc/unbound/blocklist.conf" -
Reload Unbound:
unbound-control reload
Local-Zone Types
OISD uses always_null (returns 0.0.0.0/::) by default to avoid breaking some IoT devices. Other options:
always_refuse: reply REFUSED (cleanest for debugging).always_nxdomain: fake "domain doesn't exist" (privacy-pure).always_nodata: return NOERROR with no records (rare).
IPv6, Interfaces & Debugging
IPv6 Listener Ignoring Forwarders?
If Unbound on IPv6 ignores forward-addr and falls back to recursion:
- Confirm
do-ip6: yesin server block. - Check upstream IPv6 reachability:
tcpdump -i any -n port 53 | grep IPv6. - Use
unbound-control statusto verify forwarder vs recursive mode.
Debugging Tools
- Check syntax:
unbound-checkconf - Run in foreground:
sudo unbound -d -v - Inspect cache:
unbound-control dump_cache - Test DoT:
dig @your.hostname -p 853 +tls example.com - Test DoH:
curl -X POST -d @query.bin https://your.hostname/dns-query --http2
Avoiding Common Pitfalls
- PROXY protocol + TLS on same port doesn't work: Choose either PROXY on a separate port or TLS directly in Unbound, not both.
forward-first: yesenables recursive fallback: Usenofor pure forwarding.- Unbound can't forward raw client IP: Only EDNS Client Subnet (ECS) is supported.
http took too long, droppedmeans HTTP upstream is misconfigured: Disableforward-http-upstreamif you only want plain DNS or DoT.- Let's Encrypt permissions: Unbound needs read access; ensure correct
chown/chmod.
Final Architecture
Our converged setup:
- Local cache & forwarder on UDP/TCP 53 with light tuning.
- DoT endpoint on 853 using Let's Encrypt, open to any Android Private DNS client.
- Optional DoH on IPv6:443, compiled with nghttp2.
- dnscrypt-proxy upstream handling DoH to AdGuard/NextDNS.
- EDNS/ECS tuned deliberately for balance between privacy and CDN performance.
- OISD blocklist for DNS-level ad/malware filtering.
- Access control & basic rate limiting for abuse protection.