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.[1][2][39]
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.[39]
- Supports native DoT/DoH when compiled properly.[11][17]
- Integrates seamlessly with dnscrypt-proxy for upstream privacy.[12][30][35]
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:[18][20]
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
[16][30]
DNS-over-TLS Upstream
For resolvers like Quad9 or NextDNS over DoT:[6][7]
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.[27][42][52]
Serving DoT for Android Private DNS
One central goal: "I want any Android Private DNS client to connect."[21][50]
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:[6][9][38][50]
- 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[6][21]
Serving DoH (Optional)
To serve DoH over IPv6 on port 443, Unbound must be compiled with nghttp2:[11][17]
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[11]
Privacy & Performance: EDNS and ECS
EDNS(0) Basics
Unbound supports EDNS(0) by default—there is no single "enable EDNS" toggle.[14]
EDNS Client Subnet (ECS)
To forward client subnet info to upstreams (e.g., Quad9) for better CDN geolocation:[7][8]
- 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.[7][8]
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.[8][33]
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.[21][23]
Rate Limiting
On Unbound 1.23+, use:[23]
ratelimit-queries: 100 # per-IP queries/second
ratelimit-answers: 100
ratelimit-log: yes
On Unbound 1.19.x, use iptables/nftables instead or upgrade.[23][28]
Logging Best Practices
Keep normal operation quiet:
server:
logfile: "/var/log/unbound.log"
verbosity: 1
Use unbound -d -v for temporary debugging only.[13][24][29]
Tuning for Low-Traffic, Quick Response
Threads & File Descriptors
For light load with emphasis on latency:[24]
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.[10][24]
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.[24][25]
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
[22]
Local-Zone Types
OISD uses always_null (returns 0.0.0.0/::) by default to avoid breaking some IoT devices. Other options:[22][51]
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:[15]
- 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.[15][24]
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.[26][49]
forward-first: yesenables recursive fallback: Usenofor pure forwarding.[18][20]- Unbound can't forward raw client IP: Only EDNS Client Subnet (ECS) is supported.[8][33]
http took too long, droppedmeans HTTP upstream is misconfigured: Disableforward-http-upstreamif you only want plain DNS or DoT.[16]- Let's Encrypt permissions: Unbound needs read access; ensure correct
chown/chmod.[6][9][50]
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.