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

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: yes to 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

  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
    

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

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

Comments