Skip to content
6 min read

AdGuard Home + Unbound: DNS Filtering Without Upstream Leaks

AdGuard Home sends every DNS query to one public resolver by default. Unbound as a local recursive resolver keeps your full query history off any single provider.


Disclosure: This post contains Amazon affiliate links (marked with *). As an Amazon Associate I earn from qualifying purchases. Buying through them costs you nothing extra.

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.com inside 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.lan problem above showed up.
Was this helpful?
Share
DW

David Woitzik

Hybrid Cloud Engineer

I work mostly with Azure, Terraform, and Zero-Trust network design, and publish the Terraform templates and write-ups I wanted when I was solving the same problems.

Keep reading

More like this in your inbox

New Terraform modules and articles by email. No spam.