Out of the box, AdGuard Home forwards every query it doesn’t block to a single public resolver: Quad9 over DNS-over-HTTPS in current releases, and in plenty of homelab setups 8.8.8.8 or 1.1.1.1 over plain DNS. Every lookup (every website, every API call, every telemetry ping) ends up in one provider’s logs. Unbound resolves recursively instead: it walks the DNS hierarchy from the root servers down and caches results locally, so no single upstream sees your complete query history. This same AdGuard instance also answers the internal wildcard names behind cert-manager’s TLS automation, so filtering and resolution are one control point in this network.
View the complete homelab infrastructure source on GitHub
The Leak
A typical AdGuard Home upstream config pointing at public resolvers:
upstream_dns:
- 8.8.8.8
- 1.1.1.1
Every query goes to Google or Cloudflare. Over plain DNS, your ISP sees the queries too, because they travel as cleartext UDP on port 53. DNS-over-HTTPS (or AdGuard’s Quad9 default) encrypts them in transit, so the ISP only sees a connection to the resolver’s IP, but the resolver itself still sees every name you look up. Google Public DNS keeps temporary logs with your IP address for 24 to 48 hours; Cloudflare’s 1.1.1.1 deletes logs containing IP addresses within 25 hours. Better than nothing, but your full query history still sits with one company.
Unbound talks to the root, TLD, and authoritative servers directly. Each of them only sees the queries for its own part of the tree, and with qname-minimisation enabled, the root and TLD servers see only as much of each name as they need. The trade-off is that these queries go out unencrypted, so your ISP can see them. Recursion removes the single third party with your whole history; it does not hide DNS traffic from your ISP.
Running Unbound Next to AdGuard
Unbound runs on the two Raspberry Pi 4B* edge nodes (rpi-srv-01 at 10.0.20.2, rpi-srv-02 at 10.0.20.3), in Docker next to AdGuard Home. An Ansible role deploys the klutchell/unbound image (pinned by digest) and publishes the container’s port 53 on host port 5335, because AdGuard already owns port 53 on the host:
# ansible/roles/unbound/tasks/main.yml (compose excerpt)
services:
unbound:
image: klutchell/unbound:1.26.1@sha256:9a3939ca33d6d3b42b81a70391bb8e43f0a08d2bd7a3fdffb43d427493345c36
container_name: unbound
restart: unless-stopped
mem_limit: 256m
ports:
- "5335:53/udp"
- "5335:53/tcp"
volumes:
- /opt/unbound/conf/unbound.conf:/etc/unbound/unbound.conf
- /opt/unbound/conf/root.hints:/etc/unbound/root.hints
The privacy- and cache-relevant part of the config template:
# ansible/roles/unbound/templates/unbound.conf.j2 (excerpt)
server:
interface: 0.0.0.0
port: 53
access-control: 127.0.0.0/8 allow
access-control: 10.0.0.0/8 allow
access-control: 172.16.0.0/12 allow
access-control: 192.168.0.0/16 allow
# Privacy & hardening
hide-identity: yes
hide-version: yes
harden-glue: yes
harden-below-nxdomain: yes
harden-referral-path: yes
use-caps-for-id: yes
qname-minimisation: yes
# Cache
msg-cache-size: 128m
rrset-cache-size: 256m
cache-min-ttl: 300
cache-max-ttl: 86400
cache-max-negative-ttl: 300
prefetch: yes
serve-expired: yes
root-hints: "/etc/unbound/root.hints"
There is no forward-zone for ., so Unbound recurses from the root hints instead of handing queries to anyone. interface: 0.0.0.0 is inside the container; what is reachable from the network is host port 5335. The 172.16.0.0/12 line exists because AdGuard reaches Unbound through Docker’s port publishing, so inside the container its queries come from the Docker bridge gateway, not from 127.0.0.1.
The caches (128 MB of messages, 256 MB of RRsets) are sized for a home network and fit in a 4B’s RAM; a Raspberry Pi 5* with 8GB has more headroom if you are buying new. cache-min-ttl: 300 keeps records for at least five minutes even when the zone publishes a shorter TTL, which raises the hit rate at the cost of occasionally serving a record that has already changed. prefetch refreshes popular records before they expire, and serve-expired answers from stale cache while the refresh runs.
Pointing AdGuard at Unbound
AdGuard Home’s config is templated by Ansible too. The upstream is the Unbound on the same Pi:
# ansible/roles/adguard/templates/AdGuardHome.yaml.j2 (excerpt)
dns:
upstream_dns:
- 127.0.0.1:5335
bootstrap_dns:
- 9.9.9.10
- 149.112.112.10
cache_enabled: true
cache_size: 67108864
cache_optimistic: true
AdGuard only uses bootstrap servers to resolve upstream hostnames. The upstream here is an IP address, so the Quad9 bootstrap entries are not in the path of normal client queries.
AdGuard Home handles filtering (ads, trackers, malware domains). Unbound handles resolution. Each piece does one job. AdGuard keeps its own 64 MB cache with optimistic caching on top, so a repeat lookup usually never reaches Unbound at all.
The Performance Trade-off
Recursive resolution is slower for cold queries: Unbound has to walk the DNS hierarchy, which typically adds tens of milliseconds and can reach a few hundred for distant or slow authoritative servers. Cached queries are faster than any public resolver, because the answer comes from memory on the Pi instead of a network round-trip. I have not benchmarked my own setup against a forwarding config, so there is no latency table here. dig prints a Query time line; running the same name twice against your resolver shows the cold and cached cases directly.
The slow case worth watching is a name that never resolves. In July 2026, after the move to k3s, the 7-day DNS query volume on the Pis was about 45x what it had been before (roughly 1.8M queries instead of 40k). One cause was a leftover DHCP search domain, home.lan: every lookup with that suffix recursed all the way to the root and failed, at around 341 ms each. Unbound now answers it locally with an empty static zone:
local-zone: "home.lan." static
That returns NXDOMAIN from Unbound itself, with no recursion. If your query log shows lots of failing lookups for a suffix you never created, this is the cheap fix while the DHCP side gets corrected.
DNSSEC: Check That It Is Actually On
Unbound validates DNSSEC only when it has a root trust anchor, configured with auto-trust-anchor-file. With validation on, a tampered response for a signed zone fails validation and Unbound returns SERVFAIL instead of the forged answer.
My template does not have that line, and the role mounts it over the image’s default config, which does. So my resolvers do not validate today. The test:
dig @10.0.20.2 -p 5335 dnssec-failed.org A | grep status
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 10619
dnssec-failed.org has deliberately broken signatures. A validating resolver returns SERVFAIL; NOERROR means no validation. AdGuard’s enable_dnssec setting does not change that, because it only requests DNSSEC records and passes on the upstream’s ad flag. The klutchell/unbound image already ships a root key, so the fix is one line in the server block:
auto-trust-anchor-file: "/var/unbound/root.key"
Run the dig test after any resolver change. A config that looks like it validates is not the same as one that does.
The Failover
The two Raspberry Pis run active/passive behind a Keepalived virtual IP (10.0.20.5), and clients point at the VIP, so a node failure is transparent to them. The full VRRP setup (config, priorities, failover timing) is covered in the HA DNS architecture piece; this article assumes that failover layer exists and focuses on what sits behind it: recursive, non-leaking DNS instead of a forwarder to a public resolver.
Keepalived only moves the VIP when the node or the Keepalived process itself stops sending VRRP advertisements, which takes about 3 to 4 seconds to detect. It does not watch Unbound. A crashed Unbound container is restarted by the autoheal container described in the HA article, not failed over.
Monitoring DNS
There is no Unbound exporter in my setup. Unbound exposes statistics through unbound-control stats, which needs remote-control enabled in the config, and an exporter such as unbound_exporter can turn those into Prometheus metrics. Mine has neither. What runs instead:
- The container healthcheck (
dig @127.0.0.1 google.cominside the container) plus autoheal restarts a hung Unbound. - A cron script on each Pi queries AdGuard on 53 and Unbound on 5335 from the host and posts to Discord when the state changes. It has no dependency on the k3s monitoring stack, so it still fires when the cluster is down.
- AdGuard’s query log and statistics (7-day retention) are where the
home.lanproblem above showed up.