I used to point everything at public DNS and call it good enough.
Cloudflare, Google, Quad9 — pick your flavor. They’re fast, they’re reliable, and they make DNS someone else’s problem. Which is attractive right up until you remember that DNS is one of the best visibility points in a network, and outsourcing all of it means handing someone else a clean little diary of what your clients are trying to resolve.
That doesn’t mean public resolvers are evil. It means I don’t want them to be the default answer when I can run the boring plumbing myself.
The stack I like is simple:
- AdGuard Home handles filtering, client policy, blocklists, allowlists, rewrites, and local DNS behavior.
- Unbound handles recursive resolution directly against the DNS hierarchy.
- Clients only talk to AdGuard.
- AdGuard only forwards normal recursive lookups to Unbound.
No magic. No DNS over-marketing. Just a clean split between policy and resolution.
1. Why recursive DNS instead of forwarding
Forwarding is the easy button.
A client asks AdGuard for example.com, AdGuard forwards that question to a public resolver, and the public resolver does the recursive work. That is fine for a lot of networks. It is also not really self-hosted DNS. It is self-hosted filtering bolted onto someone else’s resolver.
Recursive resolution changes the chain.
Instead of asking a public resolver to go find the answer, Unbound walks the DNS hierarchy itself:
- Ask the root servers where
.comlives. - Ask the
.comservers whereexample.comlives. - Ask the authoritative nameservers for the actual record.
- Cache the answer locally for the next client that asks.
That gives me a few things I care about:
- I am not sending every lookup to one upstream company by default.
- I can validate DNSSEC locally.
- I can keep filtering policy in AdGuard without making AdGuard responsible for recursion.
- I can tune privacy settings like QNAME minimization and ECS behavior at the resolver.
There are tradeoffs. First lookup latency can be a little higher until cache warms up. You also have to maintain the resolver properly, because apparently infrastructure does not maintain itself no matter how many dashboards you give it.
Still worth it.
Useful references:
2. The architecture
I do not put filtering and recursion in the same mental bucket.
Filtering is policy. Recursion is resolution. Mixing those responsibilities makes troubleshooting annoying, and annoying DNS is how you end up staring at packet captures at midnight like a cursed raccoon.
This Jekyll site is using kramdown/Rouge and does not advertise diagram-renderer support, so plain text wins.
Client devices
|
| DNS queries
v
AdGuard Home
10.0.0.10:53
|
| upstream DNS
v
Unbound recursive resolver
10.0.0.20:5335
|
| iterative resolution
v
Root servers -> TLD servers -> authoritative servers
The important part is the direction of trust:
- Clients trust AdGuard for DNS.
- AdGuard trusts Unbound for recursive answers.
- Unbound does not forward normal lookups to Cloudflare, Google, or some other public resolver.
- Internal-only names can be answered locally before they ever leave the network.
That last point matters. Internal DNS leakage is sloppy. Sloppy is how future-you ends up apologizing to present-you.
3. Example addressing
Use whatever addressing makes sense in your environment. For this post, I am using fake lab addresses:
| Component | Example address | Purpose |
|---|---|---|
| AdGuard Home | 10.0.0.10:53 |
Client-facing DNS and filtering |
| Unbound | 10.0.0.20:5335 |
Recursive resolver for AdGuard |
| Internal zone | home.arpa |
Placeholder split-DNS zone |
Those are placeholders. Do not cargo-cult them into production unless your network actually matches them. Even then, maybe don’t. Think first. It’s free.
4. Unbound as the recursive resolver
Unbound is the resolver. It should listen on an address AdGuard can reach, and it should recurse directly instead of forwarding to a public resolver.
A minimal conceptual Unbound configuration looks like this:
server:
interface: 0.0.0.0
port: 5335
access-control: 10.0.0.0/24 allow
do-ip4: yes
do-ip6: no
do-udp: yes
do-tcp: yes
# Privacy and correctness
qname-minimisation: yes
harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: no
# DNSSEC validation
auto-trust-anchor-file: "/var/lib/unbound/root.key"
# Do not leak client subnet data upstream
send-client-subnet: 0.0.0.0/0
The exact file path depends on how you install Unbound. Package install, container, jail, VM — pick your poison. The logic is the same.
The settings I care about most:
| Setting | Why it matters |
|---|---|
port: 5335 |
Keeps Unbound separate from AdGuard’s client-facing port 53 |
qname-minimisation: yes |
Sends the minimum necessary query name at each step of recursion |
auto-trust-anchor-file |
Enables DNSSEC trust anchor management |
send-client-subnet: 0.0.0.0/0 |
Prevents ECS from being sent upstream |
no forward-zone for . |
Keeps Unbound recursive instead of turning it into a forwarding resolver |
That last one is the big one. If you configure Unbound to forward . to a public resolver, you built a forwarder. Not a recursive stack. Different thing.
5. DNSSEC validation
DNSSEC is not encryption. It does not hide the query. It validates that the answer is authentic according to the DNSSEC chain of trust.
That distinction matters because people routinely treat DNSSEC like it is privacy pixie dust. It isn’t. It is integrity.
For Unbound, I want the root trust anchor initialized and maintained. Depending on the platform, that may be handled by the package or by a command like this:
unbound-anchor -a /var/lib/unbound/root.key
Then I verify DNSSEC behavior with known test domains:
dig @10.0.0.20 -p 5335 dnssec.works A
For a deliberately broken DNSSEC domain, I expect failure:
dig @10.0.0.20 -p 5335 dnssec-failed.org A
If the broken domain resolves cleanly, DNSSEC validation is not doing what you think it is doing. DNS will happily let you be wrong with confidence. It has a gift for that.
Useful references:
6. ECS disabled
ECS means EDNS Client Subnet.
The idea is that a resolver can include part of the client subnet in upstream queries so large providers can return geographically optimized answers. That can help CDNs. It can also leak more client-network context than I want leaving the resolver.
For a small self-hosted stack, my default is simple:
send-client-subnet: 0.0.0.0/0
That says: do not send ECS upstream.
Will this occasionally make CDN routing slightly less perfect? Maybe. Will I survive? Somehow, yes.
7. QNAME minimization
QNAME minimization is one of those settings that sounds academic until you think about what recursive DNS actually does.
Without minimization, more of the full query name can be exposed to more of the DNS hierarchy than necessary. With minimization, the resolver asks only what each layer needs to know.
Example:
Instead of exposing:
very-specific-hostname.service.example.com
to every step, the resolver walks down only as needed:
. -> com -> example.com -> service.example.com
In Unbound:
qname-minimisation: yes
It is not a silver bullet. It is just less sloppy. I like less sloppy.
8. AdGuard upstream DNS pointed at Unbound
AdGuard Home should be the only DNS server your clients know about.
In AdGuard, set the upstream DNS server to Unbound:
10.0.0.20:5335
Then set the bootstrap DNS servers only if your upstream uses hostnames. In this design, the upstream is an IP and port, so bootstrap is not the interesting part.
The flow should be:
client -> 10.0.0.10:53 -> 10.0.0.20:5335 -> recursive DNS hierarchy
What I do not want:
client -> AdGuard -> Cloudflare
client -> AdGuard -> Google
client -> random fallback resolver because someone clicked a checkbox at 1:00 AM
AdGuard is excellent at filtering and visibility. Let it do that. Let Unbound do recursion. Division of labor. Civilization depends on it.
9. Split-DNS for internal hostnames
Internal names should resolve internally.
If I have a local-only zone like home.arpa, I do not want those queries wandering out to the public DNS hierarchy where the answer is either nonexistent or embarrassing.
There are two common ways I handle it:
| Method | Best for |
|---|---|
| AdGuard DNS rewrites | Simple hostnames and a small number of records |
| Unbound local zones | Larger local zones or resolver-side authority |
For small environments, AdGuard rewrites are easy:
service.home.arpa -> 10.0.0.50
nas.home.arpa -> 10.0.0.60
For Unbound-side local zones, the shape is more like this:
server:
local-zone: "home.arpa." static
local-data: "service.home.arpa. 300 IN A 10.0.0.50"
local-data: "nas.home.arpa. 300 IN A 10.0.0.60"
Pick one place to own the records. Do not create split-brain DNS inside your split-DNS design. That is not architecture. That is a haunted house.
10. Client configuration
Clients should point to AdGuard, not Unbound.
That usually means DHCP hands out:
DNS server: 10.0.0.10
Not both AdGuard and Unbound. Not AdGuard plus a public resolver as a backup. That “backup” becomes a bypass the first time a client decides to use it.
If I want redundancy, I build a second AdGuard/Unbound pair and hand out both filtering endpoints:
DNS server 1: 10.0.0.10
DNS server 2: 10.0.0.11
Each filtering endpoint should still send recursive lookups to a controlled resolver. The goal is resilient policy, not an accidental escape hatch.
11. Verification with dig
Once the stack is wired, I test each layer separately.
Test Unbound directly
dig @10.0.0.20 -p 5335 example.com A
I want a valid answer from Unbound.
Test AdGuard path
dig @10.0.0.10 example.com A
I want the same general resolution path, but now through the filtering layer.
Test DNSSEC validation
dig @10.0.0.20 -p 5335 dnssec-failed.org A
I expect this to fail when validation is working.
Test internal split-DNS
dig @10.0.0.10 service.home.arpa A
Expected placeholder answer:
service.home.arpa. 300 IN A 10.0.0.50
Test that clients are using AdGuard
From a client:
nslookup example.com 10.0.0.10
Then check AdGuard’s query log. If the client is not visible there, it is not using the path you think it is using. DNS clients lie by omission. Routers lie by default. Operating systems lie because they can.
12. Common mistakes
A few ways this stack gets screwed up:
| Mistake | Why it is bad |
|---|---|
| AdGuard forwards to public DNS | You lose the recursive design and centralize lookup visibility upstream |
| Clients get Unbound directly | They bypass filtering and policy |
| Clients get public DNS as secondary | They bypass filtering whenever they feel like it |
| ECS left enabled accidentally | More client-network context leaks upstream |
| No split-DNS plan | Internal names leak or fail inconsistently |
| DNSSEC assumed but not tested | Integrity validation becomes decorative |
The secondary DNS mistake is especially common. People put a public resolver in there as a fallback and then act surprised when filtering is inconsistent.
It is not a fallback. It is a bypass.
13. Closing
AdGuard Home plus Unbound is not complicated, which is why I like it.
AdGuard owns the policy:
- filtering
- client visibility
- local rewrites
- blocklists and allowlists
Unbound owns the recursion:
- root/TLD/authoritative resolution
- DNSSEC validation
- QNAME minimization
- no ECS leakage
- local cache
That separation keeps the design understandable. More importantly, it keeps troubleshooting from becoming interpretive dance.
If I am building DNS for a homelab or small environment, this is the pattern I reach for first: clients talk to the filter, the filter talks to the resolver, and the resolver does the actual recursive work.
Boring. Controlled. Observable.
Exactly how DNS should be.