Six months ago, I got tired of leaking DNS queries to commercial upstreams like Google and Cloudflare. Standard ad blockers kill browser banners, but they still forward unencrypted domain lookups downstream. Pairing AdGuard Home with Unbound fixes that at the root.
AdGuard acts as your frontline filter, sinkholing ads, telemetry, and tracking domains across your entire LAN. Unbound sits directly behind it as a validating recursive resolver, communicating straight with authoritative root nameservers. After logging over 120,000 queries daily across 40+ home devices, this two-container stack has delivered rock-solid 1ms cached lookups with zero upstream profiling.
Quick start (5 min)
You can spin this stack up on any Linux machine (Debian, Ubuntu, or Raspberry Pi OS) with Docker and Docker Compose already installed.
1. Prepare Host Directory Structure
mkdir -p ~/homelab-dns/{adguard/work,adguard/conf,unbound}
cd ~/homelab-dns
2. Configure Unbound
Create your Unbound configuration file at unbound/unbound.conf. This setup enables iterative root recursion, strict DNSSEC validation, and binds an internal listening port on 5335:
cat <<'EOF' > unbound/unbound.conf
server:
verbosity: 1
interface: 0.0.0.0
port: 5335
do-ip4: yes
do-udp: yes
do-tcp: yes
do-ip6: no
prefer-ip6: no
# Access Control
access-control: 127.0.0.1/32 allow
access-control: 192.168.0.0/16 allow
access-control: 172.16.0.0/12 allow
access-control: 10.0.0.0/8 allow
# Security & Performance
hide-identity: yes
hide-version: yes
harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: no
edns-buffer-size: 1232
prefetch: yes
prefetch-key: yes
num-threads: 1
# Caching
msg-cache-size: 64m
rrset-cache-size: 128m
infra-cache-numhosts: 10000
EOF
3. Create Docker Compose File
Save the following configuration as docker-compose.yml in your working directory:
version: "3.8"
networks:
dns_net:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/24
services:
unbound:
image: mvance/unbound:latest
container_name: unbound-resolver
restart: unless-stopped
networks:
dns_net:
ipv4_address: 172.20.0.2
volumes:
- ./unbound/unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro
adguardhome:
image: adguard/adguardhome:latest
container_name: adguard-home
restart: unless-stopped
depends_on:
- unbound
networks:
dns_net:
ipv4_address: 172.20.0.3
ports:
- "53:53/tcp"
- "53:53/udp"
- "3000:3000/tcp" # Initial web setup wizard
- "8080:80/tcp" # Web dashboard
volumes:
- ./adguard/work:/opt/adguardhome/work
- ./adguard/conf:/opt/adguardhome/conf
4. Free Port 53 and Launch Containers
Ubuntu and Debian ship with systemd-resolved listening on 127.0.0.53:53 by default. That port conflict will prevent AdGuard from starting. Disable the stub listener before spinning up containers:
sudo sed -r -i.orig 's/#?DNSStubListener=yes/DNSStubListener=no/g' /etc/systemd/resolved.conf
sudo systemctl restart systemd-resolved
# Launch the stack
docker compose up -d
Open http://<YOUR_SERVER_IP>:3000 in your browser to complete the initial AdGuard Home wizard. Set the web admin interface to listen on port 80 (accessible from your host at 8080) and DNS to listen on all interfaces on port 53.
Deep dive
Understanding how queries move through this two-tier architecture makes troubleshooting lookups straightforward and intuitive.
How Query Resolution Works
Every lookup request from your network follows a clean, five-stage pipeline:
- Client Query: A device on your LAN sends a standard DNS request to your server’s IP on port 53 (UDP/TCP).
- AdGuard Filtering: AdGuard evaluates the domain against your active blocklists. If it matches a tracker or ad server, AdGuard immediately returns
0.0.0.0in under 1ms. - Iterative Resolution in Unbound: Permitted queries pass internally to Unbound at
172.20.0.2:5335. Instead of relying on a third-party recursive DNS, Unbound queries the Root zone servers (.), moves down to the TLD nameservers (like.com), and finally grabs the exact record from authoritative nameservers. - DNSSEC Validation: Unbound authenticates the cryptographic signatures along the trust chain. If signatures are forged or stripped, Unbound drops the record and returns a
SERVFAILto protect against cache poisoning. - Cache & Delivery: AdGuard stores the answer in local memory and delivers the final IP to the client device.
While cold recursive lookups take 45–90ms across the root hints chain, aggressive caching keeps perceived latency low. In daily operation, between 85% and 92% of queries hit local RAM, returning answers in under 2ms without ever touching the WAN.
Connecting AdGuard to Unbound
Open Settings > DNS settings in your AdGuard Home web panel and configure the following parameters:
- Upstream DNS servers:
172.20.0.2:5335 - Bootstrap DNS servers:
1.1.1.1:53(used exclusively to resolve initial blocklist URLs at container boot) - Upstream mode: Parallel requests or Load balancing
Click Test upstreams. A green verification badge will confirm that Unbound is resolving records as expected.
Advanced usage
With basic resolution running smoothly, you can tailor your stack for local routing and automated maintenance.
1. Split-Horizon DNS for Internal Domains
Running local services behind Traefik or Nginx Proxy Manager? Avoid hairpin NAT by routing your private domains straight to your reverse proxy (e.g., 192.168.1.50). In AdGuard Home, navigate to Filters > Custom filtering rules and add:
# Route internal domains to local reverse proxy
||home.lab^$dnsrewrite=192.168.1.50
||*.home.lab^$dnsrewrite=192.168.1.50
2. Blocklist Selection and Performance Tuning
Loading millions of redundant rules degrades performance without adding meaningful protection. For a well-balanced home network with roughly 350,000 rules, enable these under Filters > DNS blocklists:
- AdGuard DNS filter: Excellent baseline protection against web ads and common trackers.
- OISD (Basic / Full): Broad internet-wide coverage curated specifically for near-zero false positives.
- HaGeZi Multi Pro: Thorough shield against aggressive telemetry, affiliate tracking, and malware domains.
3. Automating Root Hints Updates
Unbound references root server IPs via the IANA Root Hints file. While these addresses change infrequently, refreshing the file twice a year is good operational hygiene. Add this simple monthly cron job on your host:
# Download fresh root hints on the 1st of each month and restart Unbound
0 3 1 * * curl -s -o ~/homelab-dns/unbound/root.hints https://www.internic.net/domain/named.root && docker restart unbound-resolver
Practical tips
Running a self-hosted recursive resolver comes with subtle edge cases. Here are four battle-tested recommendations to keep your network fast and stable:
- Enable Optimistic Caching: Under AdGuard’s DNS settings, set cache size to
67108864(64MB) and enable Optimistic caching. AdGuard will instantly answer clients using expired cache records while quietly refreshing them in the background, keeping web navigation instant. - Leave
use-caps-for-idDisabled: 0x20 bit randomization alters letter casing (e.g.,eXaMpLe.CoM) to prevent spoofing. Unfortunately, multiple major CDNs and authoritative nameservers still fail to parse mixed-case requests properly, triggering intermittent lookup timeouts. - Set Client-Specific Filtering: Use AdGuard’s Client settings to group IP subnets. You can enforce strict ad and malware blocking on IoT devices and smart TVs while exempting developer workstations from aggressive analytics blocks.
- Plan for Hardware Restarts: If your Docker host reboots for a kernel update, your entire household loses DNS. Set a secondary fallback IP in your router’s DHCP options or run a paired secondary Raspberry Pi instance to prevent unexpected family outages.

