Your DNS Knows Too Much: Building a Private Recursive Resolver with Unbound

By Anas Semesmieh · July 18, 2026 · Homelab, DNS, Privacy, Networking, Self-Hosting

AdGuard Home is great. It blocks ads at the DNS layer, rewrites internal subdomains, enforces safe search, and lets you inspect every query your network makes. I've had it running as a core part of my homelab stack for a while — a primary on a Proxmox LXC and a synced replica on Unraid for redundancy.

But there's something AdGuard doesn't solve on its own: every query it can't answer locally still leaves your network. By default, it forwards those queries to an upstream resolver — Quad9, Cloudflare, Google, whatever you configure. That upstream sees the full, unencrypted domain name for every site your devices visit. You've just moved the trust boundary, not eliminated it.

This week I fixed that. Here's how I replaced the forwarding upstream with Unbound — a pure recursive resolver that talks directly to the authoritative nameservers for every domain, with no third party in the middle.

What DNS Forwarding Actually Means

When most people set up AdGuard, they configure an upstream like https://dns.quad9.net/dns-query. That's DNS-over-HTTPS (DoH) — the transport is encrypted, which is good, but it doesn't change who receives the query. Quad9 still sees your full question: "what's the IP for this domain?" DoH just hides it from your ISP. Quad9 itself has full visibility.

Whether you trust Quad9 is a separate question. The point is you're trusting someone. They have your IP and a timestamped log of every domain your network looked up. That's a lot of signal about what you're doing, when, and how often.

A recursive resolver works differently. Instead of forwarding to a trusted third party, it resolves the answer itself by walking the DNS tree from the root:

You: "what's github.com?"

Unbound → Root servers (a.root-servers.net etc.)
          "Who handles .com?"
          ← "Verisign"

Unbound → Verisign (.com TLD servers)
          "Who handles github.com?"
          ← "ns1.p16.dynect.net"

Unbound → ns1.p16.dynect.net (GitHub's own nameserver)
          "What's github.com?"
          ← "140.82.121.4"

No single upstream sees the full query. The root servers only see "who handles .com?". Verisign only sees "who handles github.com?". GitHub's nameserver only sees the final question. Each hop knows as little as possible — this is called qname minimisation (RFC 7816), and Unbound enables it by default.

The privacy win in one sentence: Quad9 and Cloudflare go from seeing every domain your network queries to seeing nothing at all.

What I Already Had

Before this change, my DNS setup looked like this:

Devices → AdGuard Primary (CT101, 192.168.20.70:53)
  └─ *.semesmieh.com rewrites → 192.168.20.71 (Caddy)
  └─ Everything else → Quad9 DoH (load-balanced across 3 endpoints)

AdGuard Mirror (192.168.20.79, Unraid) — replica via adguardhome-sync on CT108

I started this week by verifying both instances were healthy — checking query counts, confirming the adguardhome-sync was propagating filter lists correctly, and making sure neither instance had stuck stats. Everything checked out: 58K+ queries/day on the primary, 29 filter lists synced identically on both sides, last sync successful. Good baseline.

Which made it a good time to improve the upstream rather than fix anything broken.

The Architecture Decision

Two choices upfront:

Run Unbound inside CT101 alongside AdGuard?

Tempting — no new container to manage, sub-millisecond loopback latency. But CT101 already uses 677 MB of RAM running AdGuard (with 29 blocklists in memory), and more importantly: I have two AdGuard instances. Both should use the same recursive resolver — which means a standalone Unbound that both can point to makes more architectural sense than one embedded in CT101 and one replica still hitting Quad9.

There's also a failure domain argument. If CT101 crashes, AdGuard and Unbound go down together. With a separate LXC, the layers fail independently.

Forwarding over DoT vs. pure recursive?

Forwarding over DNS-over-TLS to Quad9 is slightly faster on cold queries (no need to walk from root), but you're still trusting an upstream. Pure recursive is slower on the very first lookup for an uncached domain — maybe 100–300ms while it walks root → TLD → authoritative — but after that everything is cached and fast. With prefetch enabled, frequently-queried domains are renewed before their TTL expires, so common sites are always warm.

The privacy win only exists with pure recursive. That was the goal.

The Build

CT110 — a minimal LXC on ssd-lvm

Unbound is genuinely lean. 1 core, 256 MB RAM, 2 GB disk is plenty — the whole service idles at around 30 MB resident after warmup. I created it as an unprivileged Debian 12 LXC on ssd-lvm (same pool as CT101 and CT102):

pct create 110 local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst \
  --hostname unbound \
  --cores 1 \
  --memory 256 \
  --swap 0 \
  --rootfs ssd-lvm:2 \
  --net0 name=eth0,bridge=vmbr0,ip=192.168.20.80/24,gw=192.168.20.1 \
  --nameserver 192.168.20.70 \
  --unprivileged 1 \
  --onboot 1

Then installed Unbound and its dependencies:

apt-get update && apt-get install -y unbound curl dnsutils

The config

I dropped a single file at /etc/unbound/unbound.conf.d/homelab.conf. Here it is with commentary on the decisions that matter:

server:
    # Port 5335 — AdGuard owns :53, Unbound is the upstream layer
    interface: 0.0.0.0
    port: 5335
    do-ip4: yes
    do-ip6: no
    do-udp: yes
    do-tcp: yes

    # LAN-only access — no public exposure
    access-control: 127.0.0.0/8 allow
    access-control: 192.168.20.0/24 allow
    access-control: 0.0.0.0/0 refuse

    # Root hints — bootstrap list of root nameservers from IANA
    root-hints: "/var/lib/unbound/root.hints"

    # Privacy hardening
    hide-identity: yes          # don't reveal hostname in chaos queries
    hide-version: yes           # don't reveal software version
    harden-glue: yes            # reject out-of-zone glue records
    harden-dnssec-stripped: yes # reject answers with DNSSEC stripped
    harden-referral-path: yes   # validate referral paths
    use-caps-for-id: yes        # 0x20 encoding — spoofing resistance
    qname-minimisation: yes     # RFC 7816 — send minimal queries upstream

    # Performance — warm cache, prefetch before TTL expires
    prefetch: yes
    prefetch-key: yes
    num-threads: 1
    msg-cache-size: 50m
    rrset-cache-size: 100m
    cache-min-ttl: 300
    cache-max-ttl: 86400

A few settings worth explaining:

Root hints and DNSSEC trust anchor

The root hints file is just a list of the 13 root nameserver clusters — Unbound needs it to know where to start. Fetch it from IANA:

curl -sS https://www.internic.net/domain/named.cache \
  -o /var/lib/unbound/root.hints

For DNSSEC, Debian's unbound package auto-bootstraps the root trust anchor at /var/lib/unbound/root.key during install. No manual step needed — just make sure your config's auto-trust-anchor-file isn't declared twice (Debian already adds it in root-auto-trust-anchor-file.conf; duplicate declarations cause a startup error).

Wiring Up AdGuard

With Unbound running, the change in AdGuard is small — two fields in AdGuardHome.yaml:

# Before
upstream_dns:
  - https://dns.quad9.net/dns-query
  - https://dns11.quad9.net/dns-query
  - https://dns10.quad9.net/dns-query

# After
upstream_dns:
  - 192.168.20.80:5335

I kept Quad9 as the fallback — it's already there and only activates if CT110 is completely unreachable:

fallback_dns:
  - 9.9.9.9
  - 1.1.1.1

I also disabled DNSSEC validation in AdGuard (enable_dnssec: false). With Unbound doing full DNSSEC validation at the resolver level, AdGuard validating again is redundant and can cause false negatives on edge cases. One layer owns it cleanly.

The same change went to the Unraid replica. Since adguardhome-sync runs every 5 minutes and syncs config from primary to replica, this was also propagated automatically on the next cycle — but I applied it manually to both for immediate effect.

Watch out: adguardhome-sync syncs the full upstream_dns config, so once you change the primary, the next sync cycle will propagate Unbound as the upstream to the replica automatically. This is the behaviour you want — but verify it after the first sync runs so you're sure it didn't revert instead.

Verification

Three checks to confirm everything is working end-to-end:

1. Unbound resolves and validates DNSSEC

# Basic resolution
dig +short google.com @192.168.20.80 -p 5335
# → 142.250.183.46  ✓

# DNSSEC valid domain — should have the AD flag (Authenticated Data)
dig sigok.verteiltesysteme.net @192.168.20.80 -p 5335 +dnssec | grep "flags:"
# → flags: qr rd ra ad  ✓  (ad = DNSSEC authenticated by Unbound)

# DNSSEC bogus domain — should return SERVFAIL
dig sigfail.verteiltesysteme.net @192.168.20.80 -p 5335 | grep "status:"
# → status: SERVFAIL  ✓  (Unbound correctly rejects broken DNSSEC)

2. AdGuard is routing through Unbound

Pull the query log from AdGuard's API and check the upstream field:

curl -sk -u 'user:pass' 'https://adguard.yourdomain.com/control/querylog?limit=5' \
  | python3 -c "
import sys, json
for e in json.load(sys.stdin).get('data', []):
    print(e.get('question',{}).get('name'), '|', e.get('upstream',''))
"
# → region1.v2.argotunnel.com | 192.168.20.80:5335  ✓

3. Split-DNS rewrites still work

dig +short home.semesmieh.com @192.168.20.70
# → 192.168.20.71  ✓  (AdGuard rewrite still routes to Caddy)

AdGuard's own rewrites are answered locally before the query ever reaches Unbound, so split-DNS is completely unaffected.

4. Fallback test

# Stop Unbound
systemctl stop unbound

# DNS should still work — AdGuard falls back to 9.9.9.9
dig +short github.com @192.168.20.70
# → 4.237.22.38  ✓  (resolves via fallback)

# Restore
systemctl start unbound

The Result

The updated DNS flow:

Devices
  └─► AdGuard Primary  (192.168.20.70:53)  ─┐
  └─► AdGuard Replica  (192.168.20.79:53)  ─┼─► Unbound CT110 (192.168.20.80:5335)
                                              │    pure recursive · DNSSEC validated
                                             ─┘         │
                                                         └─► Root/TLD/Authoritative servers
                                                              (no middleman)

                          Fallback only: 9.9.9.9 / 1.1.1.1 (if CT110 unreachable)

What changed in practice:

Property Before After
Upstream Quad9 DoH (3 endpoints, load-balanced) Unbound CT110 — pure recursive
Who sees your queries Quad9 Nobody (root → TLD → authoritative, minimal per-hop)
DNSSEC validation AdGuard (shallow) Unbound (full, with trust anchor)
Cold query latency ~10–30ms (Quad9 is fast) ~100–300ms first lookup, then cached/prefetched
Upstream dependency Required None (fallback exists but never touched under normal operation)
qname minimisation No (full domain sent to Quad9) Yes — root servers see only what they need to

I also added Unbound to the Homepage dashboard as a simple ping tile in the Networking section, sitting alongside AdGuard Home, AdGuard Mirror, and AdGuard Sync. No widget needed — it's infrastructure, not a user-facing service. Green dot is all I need at a glance.

Total resource cost: CT110 runs at ~30 MB RAM and effectively 0% CPU at idle. The whole thing costs less than a browser tab. There's no reason not to do this.

Is This Overkill?

Depends on your threat model. If you trust Quad9 and DoH is good enough for you — totally fine. Quad9 has a solid privacy policy and blocks known malicious domains on top of that.

For me, the goal isn't distrust of Quad9 specifically. It's eliminating an unnecessary dependency. Why rely on an external service for something I can run locally, faster, with no data leaving my network, at essentially zero cost? The homelab philosophy in one sentence.

The privacy win is real and the complexity cost is low. A 256 MB LXC, a config file, and two lines changed in AdGuard. Everything else — ad blocking, split-DNS rewrites, sync between instances — works exactly as before.