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: no means pure forwarding—no recursive fallback on upstream failure.
  • With multiple forward-addr entries, 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: yes to 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

  1. Download the Unbound-format OISD list:

    curl -o /etc/unbound/blocklist.conf \
        "https://oisd.nl/api/domainlist/unbound?list=basic"
    
  2. Include in unbound.conf:

    include: "/etc/unbound/blocklist.conf"
    
  3. 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: yes in server block.
  • Check upstream IPv6 reachability: tcpdump -i any -n port 53 | grep IPv6.
  • Use unbound-control status to 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

  1. 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]
  2. forward-first: yes enables recursive fallback: Use no for pure forwarding.[18][20]
  3. Unbound can't forward raw client IP: Only EDNS Client Subnet (ECS) is supported.[8][33]
  4. http took too long, dropped means HTTP upstream is misconfigured: Disable forward-http-upstream if you only want plain DNS or DoT.[16]
  5. 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.

Comments