DNS Privacy Stack — Zero to Hero¶
Guide Info
Read time: ~60 min
Hands-on: 30-45 min
Difficulty: Intermediate
A complete guide to building an encrypted, ad-blocking DNS stack using AdGuard Home and Unbound with DNS-over-TLS. Covers installation, performance tuning, ISP hijacking workarounds, and every pain point encountered in production.
AI-Assisted Setup — Copy this prompt to your AI
Want an AI (ChatGPT, Claude, Gemini, etc.) to set up this stack for you? Copy-paste the prompt below and replace the placeholders with your values.
I want you to set up a privacy-focused DNS stack on my Ubuntu server using
AdGuard Home (Docker) + Unbound (systemd) with DNS-over-TLS.
My environment:
- Server IP: <YOUR_SERVER_IP>
- Server interface: <YOUR_INTERFACE> (find with: ip link show)
- Router IP: <YOUR_ROUTER_IP>
- LAN subnet: <YOUR_SUBNET>/24
- Docker base directory: <YOUR_DOCKER_DIR>
- Domain (if using Traefik): <YOUR_DOMAIN>
Follow this guide exactly:
https://docs.example.homelab/guides/dns-privacy-stack/
Specifically:
1. Install Unbound with the exact config from the Setup section (port 5335,
DoT forwarding to Mullvad + Quad9, serve-expired, DNSSEC, qname-minimisation)
2. Add systemd hardening (LimitNOFILE, Restart=on-failure, NO WatchdogSec)
3. Deploy AdGuard Home via Docker with host networking on port 53
4. Configure AdGuard Home with ALL settings from the "Key AdGuard Settings
Checklist" table — including fallback DNS (Mullvad/Quad9/dns0.eu DoT),
bootstrap DNS (Unbound + Quad9), 10MB cache, parallel upstream mode,
safe browsing, and all optimizations
5. Add all 18 blocklists from the Resources section (use the full URLs)
6. Add the allowlist rules from allowlist-rules.txt to prevent breaking
YouTube, Facebook, Instagram, WhatsApp, Spotify, Discord, etc.
7. Persist DNS via netplan (dhcp4-overrides, use-dns: false)
8. Configure router DHCP to hand out my server as DNS (DHCP option 6)
9. Verify the full stack works: Unbound direct, AdGuard via localhost,
AdGuard via LAN IP, system resolver, cached latency (should be 0ms)
Test each step before moving to the next. If anything fails, diagnose
before continuing. Do not skip the allowlist — aggressive blocklists
WILL break apps without it.
Who Is This For?¶
This guide is for anyone running a homelab or self-hosted infrastructure who wants to:
- Block ads and trackers network-wide — every device on your network (phones, laptops, smart TVs, IoT) gets ad blocking without installing anything on each device
- Stop your ISP from spying on your DNS — all queries are encrypted via DNS-over-TLS so your ISP cannot see which domains you visit
- Bypass ISP DNS hijacking — ISPs in many countries transparently redirect DNS queries to their own servers. This stack uses port 853 which ISPs don't intercept
- Eliminate DNS-related lag in video calls and gaming — optimized caching serves answers in 0ms from cache, preventing the stuttering and disconnects caused by DNS timeouts
- Survive DNS failures gracefully — stale cache serving, auto-restart, and right-sized memory ensure one bad upstream moment doesn't take down your entire network
What You Get¶
| Before | After |
|---|---|
| ISP sees every domain you visit | ISP sees only that you connect to Mullvad/Quad9 on port 853 |
| Ads on every device | Network-wide ad blocking (no per-device setup) |
| DNS failures take down all services | Stale cache serves instantly, auto-restart on failure |
| Every device needs its own ad blocker | One DNS server covers your entire network |
| ISP hijacks or redirects your queries | Port 853 (TLS) bypasses all port 53 hijacking |
| DNS config lost on reboot | Netplan override persists through reboots |
Real-World Problems This Solves¶
Ads on Smart TVs, phones, and IoT devices
Most smart TVs and IoT devices don't support ad blockers. With network-wide DNS filtering, ads are blocked at the DNS level before they reach any device — including YouTube pre-roll ads on smart TVs, in-app ads on phones, and telemetry from IoT devices.
ISP tracking and selling your browsing data
Your ISP can see every domain you resolve in plaintext. Many ISPs log this data, sell it to advertisers, or use it for targeted advertising. DNS-over-TLS encrypts all queries so your ISP only sees that you connect to a DNS server — not what you're resolving.
ISP DNS hijacking and NXDOMAIN redirection
Some ISPs hijack failed DNS queries and redirect them to ad-filled search pages. Others transparently redirect all port 53 traffic to their own resolvers, breaking DNSSEC and privacy. This stack bypasses hijacking entirely by using port 853 (TLS).
Slow DNS causing buffering, game lag, or connection drops
Default ISP DNS resolvers are often slow or overloaded. A stalled DNS query during a video call causes stuttering; during gaming it causes disconnects. With two-layer caching and serve-expired, cached domains resolve in 0ms — your apps never wait on DNS.
Malware and phishing domain blocking
Both AdGuard (via blocklists) and Quad9 (via threat intelligence) block known malicious and phishing domains. This provides network-wide protection without installing endpoint security on every device.
TL;DR — Just the Commands
For experienced users who just want the commands. Replace <YOUR_ROUTER_IP>, <YOUR_SERVER_IP>, and <YOUR_INTERFACE> with your values.
1. Install and configure Unbound:
sudo apt update && sudo apt install -y unbound dns-root-data
sudo tee /etc/unbound/unbound.conf.d/adguard.conf << 'EOF'
server:
interface: 127.0.0.1
port: 5335
do-ip6: no
do-ip4: yes
do-udp: yes
do-tcp: yes
num-threads: 2
rrset-cache-size: 64m
msg-cache-size: 32m
key-cache-size: 16m
neg-cache-size: 8m
msg-cache-slabs: 2
rrset-cache-slabs: 2
infra-cache-slabs: 2
key-cache-slabs: 2
cache-min-ttl: 300
cache-max-ttl: 86400
prefetch: yes
serve-expired: yes
serve-expired-ttl: 3600
serve-expired-client-timeout: 1800
infra-cache-numhosts: 10000
so-reuseport: yes
edns-buffer-size: 1232
auto-trust-anchor-file: /var/lib/unbound/root.key
val-permissive-mode: yes
qname-minimisation: yes
harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: no
minimal-responses: yes
hide-identity: yes
hide-version: yes
tls-cert-bundle: /etc/ssl/certs/ca-certificates.crt
verbosity: 1
access-control: 127.0.0.0/8 allow
access-control: 0.0.0.0/0 refuse
private-domain: "lan"
private-domain: "ts.net"
domain-insecure: "lan"
domain-insecure: "ts.net"
forward-zone:
name: "lan"
forward-addr: <YOUR_ROUTER_IP>
forward-zone:
name: "ts.net"
forward-addr: <YOUR_ROUTER_IP>
forward-zone:
name: "."
forward-tls-upstream: yes
forward-addr: 194.242.2.2@853#dns.mullvad.net
forward-addr: 194.242.2.3@853#adblock.dns.mullvad.net
forward-addr: 9.9.9.9@853#dns.quad9.net
forward-addr: 149.112.112.112@853#dns.quad9.net
EOF
sudo unbound-checkconf
sudo systemctl restart unbound
dig @127.0.0.1 -p 5335 google.com +short +timeout=5
2. Add systemd hardening:
sudo mkdir -p /etc/systemd/system/unbound.service.d
sudo tee /etc/systemd/system/unbound.service.d/override.conf << 'EOF'
[Service]
LimitNOFILE=65536
LimitNPROC=512
Restart=on-failure
RestartSec=5
EOF
sudo systemctl daemon-reload && sudo systemctl restart unbound
3. Deploy AdGuard Home (add to your docker-compose.yml):
adguardhome:
image: adguard/adguardhome:latest
container_name: adguardhome
network_mode: host
restart: unless-stopped
volumes:
- ./appdata/adguardhome/work:/opt/adguardhome/work
- ./appdata/adguardhome/conf:/opt/adguardhome/conf
docker compose up -d adguardhome
# Setup wizard at http://<YOUR_SERVER_IP>:3000
# Set web interface port to 8091, DNS listen to 0.0.0.0:53
# Set upstream DNS to 127.0.0.1:5335
4. Configure AdGuard (after setup wizard):
docker exec adguardhome sed -i 's/ allowed_clients:/ allowed_clients:\n - 127.0.0.0\/8/' \
/opt/adguardhome/conf/AdGuardHome.yaml
docker exec adguardhome sed -i 's/ratelimit: 20/ratelimit: 0/' \
/opt/adguardhome/conf/AdGuardHome.yaml
docker exec adguardhome sed -i 's/cache_size: 4096/cache_size: 50000/' \
/opt/adguardhome/conf/AdGuardHome.yaml
docker exec adguardhome sed -i 's/cache_optimistic: false/cache_optimistic: true/' \
/opt/adguardhome/conf/AdGuardHome.yaml
docker exec adguardhome sed -i 's/cache_ttl_min: 0/cache_ttl_min: 300/' \
/opt/adguardhome/conf/AdGuardHome.yaml
docker restart adguardhome
5. Persist DNS and configure router:
sudo tee /etc/netplan/99-dns-override.yaml << 'EOF'
network:
version: 2
ethernets:
<YOUR_INTERFACE>:
nameservers:
addresses: [127.0.0.1]
dhcp4-overrides:
use-dns: false
EOF
sudo chmod 600 /etc/netplan/99-dns-override.yaml
sudo netplan apply
# On GL.iNet/OpenWrt router:
ssh root@<YOUR_ROUTER_IP>
uci set dhcp.@dnsmasq[0].force_dns='0'
uci add_list dhcp.lan.dhcp_option='6,<YOUR_SERVER_IP>'
uci commit dhcp
/etc/init.d/dnsmasq restart
6. Verify everything works:
dig @127.0.0.1 -p 5335 google.com +short +timeout=5 # Unbound
dig @127.0.0.1 google.com +short +timeout=5 # AdGuard
dig @<YOUR_SERVER_IP> google.com +short +timeout=5 # LAN
dig @127.0.0.1 google.com +timeout=5 | grep "Query time" # Should be 0ms on 2nd run
ss -tnp | grep :853 # Verify DoT connections
Prerequisites¶
- A Linux server (Ubuntu 22.04/24.04) with Docker installed
- Basic understanding of DNS (what resolvers and records are)
- Router access for DHCP configuration (OpenWrt/GL.iNet examples provided)
- Familiarity with Core Stack services
Reading Order¶
- 01-fundamentals — What each component does and why you need it
- 02-architecture — How traffic flows through the stack
- 03-setup — Complete installation from scratch
- 04-optimization — Performance tuning for real-time apps
- 05-troubleshooting — Every pain point and how to fix it
- 06-resources — Official docs, community guides, further reading
DNS Fundamentals¶
What is DNS and Why Does It Matter for Privacy?¶
Every time you visit a website, your device asks a DNS resolver to translate the domain name (e.g., google.com) into an IP address (e.g., 142.251.220.206). By default, these queries are sent in plaintext to your ISP's DNS servers. This means:
- Your ISP sees every domain you visit
- Your ISP can hijack queries and redirect them (common in Asia, South America)
- Your ISP can inject ads or block domains at the DNS level
- Anyone on the network path can sniff your DNS traffic
A private DNS stack eliminates all of these problems.
The Components¶
AdGuard Home — DNS Filter and Ad Blocker¶
AdGuard Home is a network-wide DNS sinkhole that blocks ads, trackers, and malicious domains at the DNS level. It sits at the front of the stack, receiving all DNS queries from your network.
What it does:
- Blocks ads and trackers using community-maintained blocklists
- Provides a web UI for monitoring DNS queries and managing rules
- Supports DNS rewrites (e.g.,
*.example.com-><YOUR_SERVER_IP>) - Handles client access control and rate limiting
- Caches responses at the application layer
What it does NOT do:
- It does not encrypt DNS queries to upstream resolvers (that's Unbound's job)
- It does not do recursive resolution
- It is not a caching-only resolver (its cache is supplementary)
Unbound — Caching DNS Forwarder with Encryption¶
Unbound is a validating, recursive, caching DNS resolver. In this stack, we use it as a caching forwarder with DNS-over-TLS rather than a recursive resolver (see Troubleshooting for why).
What it does:
- Forwards queries over encrypted TLS connections (port 853) to privacy-respecting upstream resolvers
- Caches responses aggressively — deduplicate queries from 100+ Docker containers
- Serves stale cache instantly when upstream is slow (critical for real-time apps)
- Validates DNSSEC signatures
- Handles local domain forwarding (
.lan,.ts.netstay on the LAN)
Upstream Resolvers — Mullvad and Quad9¶
These are the external DNS providers that actually resolve domain names. We chose them for privacy:
| Provider | IP | Port | Logging | Jurisdiction | Notes |
|---|---|---|---|---|---|
| Mullvad DNS | 194.242.2.2 |
853 (DoT) | No logs | Sweden | Also offers ad-blocking variant |
| Mullvad DNS (adblock) | 194.242.2.3 |
853 (DoT) | No logs | Sweden | Blocks ads + trackers at resolver level |
| Quad9 | 9.9.9.9 |
853 (DoT) | No logs | Switzerland | Non-profit, threat blocking |
| Quad9 (secondary) | 149.112.112.112 |
853 (DoT) | No logs | Switzerland | Anycast redundancy |
Recursive vs Forwarding — Why We Forward¶
Unbound can operate in two modes:
Recursive Mode (Not Used)¶
Client → AdGuard → Unbound → Root Servers → TLD Servers → Authoritative Servers
In recursive mode, Unbound talks directly to the DNS hierarchy — root servers, then TLD servers (.com, .net), then authoritative servers. No single third party sees all your queries.
Why we don't use it: ISPs with transparent DNS hijacking intercept all port 53 traffic and redirect it to their own servers. Root servers expect non-recursive queries (RD=0), but the ISP's hijacking proxy can't handle these, causing every query to time out. See ISP DNS Hijacking for the full diagnosis.
Forwarding Mode with DoT (What We Use)¶
Client → AdGuard → Unbound --[TLS]--> Mullvad/Quad9 (port 853)
In forwarding mode, Unbound sends queries over an encrypted TLS connection to trusted resolvers on port 853. The ISP cannot hijack port 853 (only port 53), and the TLS encryption means they cannot read the query content even if they could intercept it.
DNS-over-TLS (DoT) Explained¶
DNS-over-TLS wraps standard DNS queries inside a TLS tunnel, similar to how HTTPS encrypts web traffic.
| Protocol | Port | Encrypted | ISP Can Read | ISP Can Hijack |
|---|---|---|---|---|
| Plain DNS | 53 (UDP/TCP) | No | Yes | Yes |
| DNS-over-TLS | 853 (TCP) | Yes | No | No (different port) |
| DNS-over-HTTPS | 443 (TCP) | Yes | No | No (blends with HTTPS) |
We use DoT because Unbound has native support for it via forward-tls-upstream: yes. No additional software is needed.
Key Terminology¶
| Term | Meaning |
|---|---|
| Upstream | The DNS server that receives forwarded queries (Mullvad, Quad9) |
| Downstream | Clients sending queries to your DNS (laptops, phones, containers) |
| DNSSEC | Cryptographic signing of DNS records to prevent tampering |
| EDNS | Extension to DNS protocol — enables larger responses, client subnet hints |
| TTL | Time-to-live — how long a DNS answer can be cached before re-querying |
| SERVFAIL | DNS error meaning the resolver couldn't get an answer |
| NXDOMAIN | DNS response meaning the domain does not exist |
| Sinkhole | Blocking a domain by returning a fake/empty response (what AdGuard does) |
| Prefetch | Re-querying domains before their cache entry expires |
| Serve-expired | Returning a stale cached answer immediately while refreshing in background |
Architecture¶
Traffic Flow¶
flowchart TD
subgraph LAN["LAN Devices"]
L[Laptop / Phone / IoT]
end
subgraph Router["GL.iNet Router"]
DHCP["DHCP Server<br/>Option 6: <YOUR_SERVER_IP>"]
end
subgraph Server["Server (<YOUR_SERVER_IP>)"]
AG["AdGuard Home<br/>0.0.0.0:53<br/>Docker, host network"]
UB["Unbound<br/>127.0.0.1:5335<br/>systemd service"]
end
subgraph Upstream["Encrypted Upstream (port 853)"]
MV["Mullvad DNS<br/>194.242.2.2"]
Q9["Quad9<br/>9.9.9.9"]
end
subgraph Local["Local Resolution"]
RT["Router<br/><YOUR_ROUTER_IP>"]
end
L -->|"DNS query"| DHCP
DHCP -->|"<YOUR_SERVER_IP>:53"| AG
AG -->|"127.0.0.1:5335"| UB
UB -->|"TLS encrypted<br/>port 853"| MV
UB -->|"TLS encrypted<br/>port 853"| Q9
UB -->|".lan / .ts.net<br/>plaintext, LAN only"| RT
Component Responsibilities¶
| Layer | Component | Runs As | Listens On | Responsibility |
|---|---|---|---|---|
| 1 | Router DHCP | OpenWrt service | N/A | Tells clients to use <YOUR_SERVER_IP> as DNS |
| 2 | AdGuard Home | Docker container (host network) | 0.0.0.0:53 |
Ad blocking, DNS rewrites, access control |
| 3 | Unbound | systemd service | 127.0.0.1:5335 |
Caching, DoT encryption, local domain routing |
| 4 | Mullvad/Quad9 | External service | *:853 |
Actual DNS resolution (no logging) |
Network Topology¶
flowchart TD
subgraph Internet
ISP["ISP Gateway<br/><i>Hijacks port 53<br/>Cannot touch port 853</i>"]
end
subgraph Router["GL.iNet Router — <YOUR_ROUTER_IP>"]
DHCP["DHCP Server<br/>Hands out DNS=<YOUR_SERVER_IP><br/>force_dns=0"]
end
subgraph Server["Homelab Server — <YOUR_SERVER_IP>"]
AG["AdGuard Home<br/>0.0.0.0:53 (Docker)<br/>Filtering + Ad Blocking"]
UB["Unbound<br/>127.0.0.1:5335 (systemd)<br/>Caching + Encryption"]
end
subgraph Upstream["Privacy Resolvers (port 853, TLS)"]
MV["Mullvad DNS<br/>Sweden"]
Q9["Quad9<br/>Switzerland"]
end
ISP --- Router
Router -->|"LAN <YOUR_SUBNET>/24"| Server
AG -->|"127.0.0.1:5335"| UB
UB -->|"DoT (TLS)"| MV
UB -->|"DoT (TLS)"| Q9
Port Map¶
| Port | Protocol | Direction | Purpose |
|---|---|---|---|
| 53 | UDP/TCP | Inbound (LAN) | AdGuard receives DNS queries |
| 5335 | UDP/TCP | Localhost only | Unbound receives from AdGuard |
| 853 | TCP+TLS | Outbound | Unbound → Mullvad/Quad9 (encrypted) |
| 8091 | HTTP | Inbound (LAN) | AdGuard web UI |
Local Domain Routing¶
Not all queries should leave the network. Local domains are forwarded to the router instead of going over DoT:
| Domain | Forward To | Why |
|---|---|---|
*.lan |
<YOUR_ROUTER_IP> |
Local network hostnames |
*.ts.net |
<YOUR_ROUTER_IP> |
Tailscale MagicDNS |
*.example.com |
AdGuard DNS rewrite | Custom domain → local IP |
This is configured in Unbound via forward-zone entries and private-domain / domain-insecure directives.
What Your ISP Sees¶
| Without This Stack | With This Stack |
|---|---|
| Every domain you visit (plaintext) | Connections to 194.242.2.2:853 and 9.9.9.9:853 |
| Can hijack/redirect queries | Cannot intercept (wrong port, encrypted) |
| Can inject fake responses | Cannot modify (TLS integrity) |
| Full browsing history via DNS | Only that you use Mullvad/Quad9 DNS |
Caching Layers¶
Queries pass through two caching layers for maximum performance:
flowchart LR
Q["Query: google.com"] --> AGC{"AdGuard Cache<br/>50,000 entries<br/>min TTL 300s<br/>optimistic: true"}
AGC -->|"HIT"| R1["0ms response"]
AGC -->|"MISS"| UBC{"Unbound Cache<br/>64MB rrset + 32MB msg<br/>serve-expired: yes<br/>prefetch: yes"}
UBC -->|"HIT"| R2["0-1ms response"]
UBC -->|"MISS or STALE"| DoT["DoT to Mullvad<br/>~40ms"]
UBC -->|"STALE + client-timeout"| R3["0ms stale response<br/>(refresh in background)"]
DoT --> R4["~40ms response"]
The serve-expired-client-timeout: 1800 setting in Unbound and cache_optimistic: true in AdGuard ensure that clients never wait for upstream resolution — they get a cached (possibly stale) answer immediately while the fresh answer is fetched in the background.
Setup — From Scratch to Working DNS¶
This guide assumes a fresh Ubuntu 22.04/24.04 server with Docker installed. Adapt paths and IPs to your environment.
Step 1: Install Unbound¶
sudo apt update
sudo apt install -y unbound dns-root-data
Verify installation:
unbound -V | head -5
Step 2: Configure Unbound¶
Create the configuration file at /etc/unbound/unbound.conf.d/adguard.conf:
sudo tee /etc/unbound/unbound.conf.d/adguard.conf << 'EOF'
Paste the following configuration:
server:
interface: 127.0.0.1
port: 5335 # (1)
do-ip6: no
do-ip4: yes
do-udp: yes
do-tcp: yes
num-threads: 2
# Cache sizes (right-sized for homelab, ~120MB total)
rrset-cache-size: 64m
msg-cache-size: 32m
key-cache-size: 16m
neg-cache-size: 8m
# Thread-aligned slabs (must match num-threads)
msg-cache-slabs: 2
rrset-cache-slabs: 2
infra-cache-slabs: 2
key-cache-slabs: 2
# Cache tuning
cache-min-ttl: 300
cache-max-ttl: 86400
prefetch: yes
serve-expired: yes # (2)
serve-expired-ttl: 3600
serve-expired-client-timeout: 1800 # (3)
# Upstream server tracking
infra-cache-numhosts: 10000
# Kernel distributes queries across threads
so-reuseport: yes
# DNS flag day recommended buffer size
edns-buffer-size: 1232
# DNSSEC (permissive — log failures, don't block)
auto-trust-anchor-file: /var/lib/unbound/root.key
val-permissive-mode: yes # (4)
# Privacy
qname-minimisation: yes
# Hardening
harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: no
minimal-responses: yes
hide-identity: yes
hide-version: yes
# TLS certificate bundle for DoT
tls-cert-bundle: /etc/ssl/certs/ca-certificates.crt
# Logging (1 = errors only, increase to 3-5 for debugging)
verbosity: 1
# Access control
access-control: 127.0.0.0/8 allow
access-control: 0.0.0.0/0 refuse
# Local domains (allow private IPs in responses)
private-domain: "lan"
private-domain: "ts.net"
private-domain: "example.com"
private-domain: "server.lan"
# Skip DNSSEC for local domains
domain-insecure: "lan"
domain-insecure: "ts.net"
## Local domains -> router (plaintext, stays on LAN)
forward-zone:
name: "lan"
forward-addr: <YOUR_ROUTER_IP>
forward-zone:
name: "ts.net"
forward-addr: <YOUR_ROUTER_IP>
## Everything else -> encrypted DoT
forward-zone:
name: "."
forward-tls-upstream: yes # (5)
# Mullvad DNS - no logging, Swedish jurisdiction
forward-addr: 194.242.2.2@853#dns.mullvad.net # (6)
forward-addr: 194.242.2.3@853#adblock.dns.mullvad.net
# Quad9 - no logging, Swiss non-profit
forward-addr: 9.9.9.9@853#dns.quad9.net
forward-addr: 149.112.112.112@853#dns.quad9.net
EOF
- Port 5335 avoids conflict with AdGuard Home on port 53. AdGuard forwards to Unbound on this port.
- Serves expired cache entries instead of returning SERVFAIL when upstream is down. Critical for reliability.
- If upstream hasn't responded in 1.8 seconds, serve the stale cache entry immediately. Clients never wait.
- Permissive DNSSEC mode logs validation failures but doesn't block responses. Prevents false positives from breaking resolution.
- Enables DNS-over-TLS for all queries to upstream servers. All DNS traffic is encrypted on the wire.
@853specifies the DoT port.#dns.mullvad.netis the TLS authentication name — Unbound verifies the server certificate matches this hostname.
Validate and Start¶
## Validate config
sudo unbound-checkconf
## Restart service
sudo systemctl restart unbound
## Test
dig @127.0.0.1 -p 5335 google.com +short +timeout=5
Add Systemd Hardening¶
sudo mkdir -p /etc/systemd/system/unbound.service.d
sudo tee /etc/systemd/system/unbound.service.d/override.conf << 'EOF'
[Service]
LimitNOFILE=65536
LimitNPROC=512
Restart=on-failure
RestartSec=5
EOF
sudo systemctl daemon-reload
sudo systemctl restart unbound
Do NOT use WatchdogSec
Unbound does not send systemd watchdog pings. Setting WatchdogSec will cause systemd to kill Unbound every 60 seconds. Use Restart=on-failure instead.
Step 3: Deploy AdGuard Home¶
Docker Compose¶
Add to your compose file (host networking required for DNS on port 53):
adguardhome:
image: adguard/adguardhome:latest
container_name: adguardhome
network_mode: host
restart: unless-stopped
volumes:
- ./appdata/adguardhome/work:/opt/adguardhome/work
- ./appdata/adguardhome/conf:/opt/adguardhome/conf
docker compose up -d adguardhome
Initial Setup¶
- Open
http://<server-ip>:3000for the setup wizard - Set the web interface to port
8091(avoid conflicts with Traefik on 443) - Set the DNS listen to
0.0.0.0:53 - Create admin credentials
Critical Configuration¶
After the setup wizard, edit the config directly for settings not exposed in the UI:
docker exec adguardhome vi /opt/adguardhome/conf/AdGuardHome.yaml
Or apply via sed:
## Upstream: point to Unbound
## (already set during wizard if you entered 127.0.0.1:5335)
## Add localhost to allowed clients (CRITICAL!)
docker exec adguardhome sed -i 's/ allowed_clients:/ allowed_clients:\n - 127.0.0.0\/8/' \
/opt/adguardhome/conf/AdGuardHome.yaml
## Disable ratelimit (all clients are trusted LAN)
docker exec adguardhome sed -i 's/ratelimit: 20/ratelimit: 0/' \
/opt/adguardhome/conf/AdGuardHome.yaml
## Set upstream mode to parallel
docker exec adguardhome sed -i 's/upstream_mode: load_balance/upstream_mode: parallel/' \
/opt/adguardhome/conf/AdGuardHome.yaml
## Increase cache size (default 4096 is tiny — set to 10 MB)
docker exec adguardhome sed -i 's/cache_size: 4096/cache_size: 10000000/' \
/opt/adguardhome/conf/AdGuardHome.yaml
## Enable optimistic caching
docker exec adguardhome sed -i 's/cache_optimistic: false/cache_optimistic: true/' \
/opt/adguardhome/conf/AdGuardHome.yaml
## Set cache TTL min (10 minutes) and max (1 day)
docker exec adguardhome sed -i 's/cache_ttl_min: 0/cache_ttl_min: 600/' \
/opt/adguardhome/conf/AdGuardHome.yaml
docker exec adguardhome sed -i 's/cache_ttl_max: 0/cache_ttl_max: 86400/' \
/opt/adguardhome/conf/AdGuardHome.yaml
## Set upstream timeout to 5s for faster failover
docker exec adguardhome sed -i 's/upstream_timeout: 10s/upstream_timeout: 5s/' \
/opt/adguardhome/conf/AdGuardHome.yaml
## Increase max goroutines for concurrent query handling
docker exec adguardhome sed -i 's/max_goroutines: 300/max_goroutines: 500/' \
/opt/adguardhome/conf/AdGuardHome.yaml
## Increase blocked response TTL (reduce re-queries)
docker exec adguardhome sed -i 's/blocked_response_ttl: 10/blocked_response_ttl: 60/' \
/opt/adguardhome/conf/AdGuardHome.yaml
## Enable safe browsing and increase its cache
docker exec adguardhome sed -i 's/safebrowsing_enabled: false/safebrowsing_enabled: true/' \
/opt/adguardhome/conf/AdGuardHome.yaml
docker exec adguardhome sed -i 's/safebrowsing_cache_size: 1048576/safebrowsing_cache_size: 4194304/' \
/opt/adguardhome/conf/AdGuardHome.yaml
## Update blocklist interval to 12 hours
docker exec adguardhome sed -i 's/filters_update_interval: 24/filters_update_interval: 12/' \
/opt/adguardhome/conf/AdGuardHome.yaml
## Restart to apply
docker restart adguardhome
Allowlist Rules¶
Aggressive blocklists (HaGeZi Ultimate, OISD Full) will break popular apps — Facebook, Instagram, YouTube, WhatsApp, Spotify, and more. A comprehensive allowlist prevents this.
Download and paste the rules into Filters > Custom filtering rules:
- allowlist-rules.txt — 170+ rules covering Google, YouTube, Facebook, Instagram, WhatsApp, Apple, Amazon/Alexa, Spotify, Discord, Twitter/X, Reddit, TikTok, Snapchat, Netflix, Zoom, Slack, Steam, PlayStation, Xbox, push notifications, CDNs, captchas, connectivity checks, and banking/payments.
Monitor for false positives
After applying the allowlist, monitor the AdGuard query log for the first week. If an app breaks, find the blocked domain in the log and add @@||domain.com^$important to your custom filtering rules.
Key AdGuard Settings Checklist¶
| Setting | Value | Why |
|---|---|---|
upstream_dns |
127.0.0.1:5335 |
Forward to Unbound |
bootstrap_dns |
127.0.0.1, 9.9.9.9 |
Unbound first, Quad9 failsafe (resolves fallback hostnames) |
fallback_dns |
Mullvad, Quad9, dns0.eu (DoT) | Privacy-first encrypted fallbacks if Unbound is down |
allowed_clients |
127.0.0.0/8, <YOUR_SUBNET>/24, 100.64.0.0/10 |
Allow localhost + LAN + Tailscale |
upstream_mode |
parallel |
Query all upstreams, use fastest response |
ratelimit |
0 |
Prevent dropping queries from containers |
cache_size |
10000000 (10 MB) |
Large cache for 100+ containers |
cache_ttl_min |
600 |
Force 10 min minimum cache |
cache_ttl_max |
86400 |
Cap at 1 day to prevent stale records |
cache_optimistic |
true |
Serve stale while refreshing |
aaaa_disabled |
true |
Skip IPv6 lookups (faster if no IPv6) |
enable_dnssec |
true |
Validate DNSSEC signatures |
edns_client_subnet |
false |
Don't leak subnet to upstreams (privacy) |
upstream_timeout |
5s |
Fast failover to fallback DNS |
max_goroutines |
500 |
Handle more concurrent queries |
safebrowsing_enabled |
true |
Block known malicious domains |
blocked_response_ttl |
60 |
Clients stop re-querying blocked domains |
filters_update_interval |
12 |
Update blocklists every 12 hours |
Fallback DNS (Privacy-First)¶
If Unbound goes down, AdGuard Home falls back to these no-log, encrypted resolvers:
| Provider | Fallback URL | Logging | Jurisdiction |
|---|---|---|---|
| Mullvad DNS | tls://doh.mullvad.net |
None (audited by Cure53) | Sweden |
| Quad9 | tls://dns.quad9.net |
No IP logging | Switzerland |
| dns0.eu zero | tls://zero.dns0.eu |
No personal data | France/EU-only infra |
Bootstrap DNS resolves the fallback hostnames. It uses Unbound first (127.0.0.1), with Quad9 (9.9.9.9) as a failsafe — so if Unbound is down, bootstrap can still resolve the DoT fallback hostnames.
Step 4: Persist resolv.conf¶
The server itself needs to use its own DNS. Without persistence, DHCP overwrites resolv.conf on reboot.
sudo tee /etc/netplan/99-dns-override.yaml << 'EOF'
network:
version: 2
ethernets:
<YOUR_INTERFACE>:
nameservers:
addresses: [127.0.0.1]
dhcp4-overrides:
use-dns: false
EOF
sudo chmod 600 /etc/netplan/99-dns-override.yaml
sudo netplan apply
Adapt the interface name
Replace <YOUR_INTERFACE> with your server's network interface. Find it with ip link show.
Verify:
cat /etc/resolv.conf
## Should show: nameserver 127.0.0.1
Step 5: Configure Router DHCP¶
All LAN devices need to use your DNS server. Configure your router's DHCP to hand out <YOUR_SERVER_IP> as the DNS server.
GL.iNet / OpenWrt¶
## SSH into router
ssh -o HostKeyAlgorithms=+ssh-rsa root@<YOUR_ROUTER_IP>
## CRITICAL: Disable DNS hijacking first
uci set dhcp.@dnsmasq[0].force_dns='0'
## Tell DHCP to hand clients your DNS server directly
uci add_list dhcp.lan.dhcp_option='6,<YOUR_SERVER_IP>'
uci commit dhcp
/etc/init.d/dnsmasq restart
Disable force_dns FIRST
GL.iNet routers have force_dns='1' by default, which creates iptables rules that hijack ALL port 53 traffic passing through the router. This causes DNS loops when clients try to reach your DNS server. Always disable it before configuring DHCP option 6.
Do NOT set noresolv or change WAN DNS
Changing the router's own upstream DNS (noresolv='1' + server='<YOUR_SERVER_IP>') can break the router's DNS and take down your entire network. The DHCP option 6 approach is safer — it only affects clients, not the router itself.
Verify on Client¶
After renewing DHCP on your laptop:
- Windows:
ipconfig /release && ipconfig /renew - Mac:
sudo ipconfig set en0 DHCP - Linux: reconnect to WiFi or
sudo dhclient -r && sudo dhclient
Then check:
## Should show <YOUR_SERVER_IP>
nslookup google.com
Step 6: Verify the Full Stack¶
## Unbound direct
dig @127.0.0.1 -p 5335 google.com +short +timeout=5
## AdGuard via localhost
dig @127.0.0.1 google.com +short +timeout=5
## AdGuard via LAN IP
dig @<YOUR_SERVER_IP> google.com +short +timeout=5
## System resolver
ping -c 1 google.com
## Cached latency (should be 0ms on second query)
dig @127.0.0.1 google.com +timeout=5 | grep "Query time"
All should return valid IPs. Cached queries should show 0 msec.
Optimization¶
This section covers every performance tuning applied to the stack, why each setting matters, and how it impacts real-time applications like video calls and gaming.
Unbound Performance Settings¶
Cache Sizing¶
The most critical setting. Oversized caches cause swap thrashing; undersized caches cause cache misses.
| Setting | Recommended | Why |
|---|---|---|
rrset-cache-size |
64m |
DNS record cache. Rule: 2x msg-cache |
msg-cache-size |
32m |
Query response cache. Rule: rrset/2 |
key-cache-size |
16m |
DNSSEC key cache |
neg-cache-size |
8m |
NXDOMAIN cache |
Total: ~120MB — comfortably fits in RAM for any homelab server. The previous config used 1.8GB (1024m + 512m + 256m) which caused swap thrashing and a cascading DNS outage after 5 days.
Do NOT set caches to 1GB+
On a 16GB server running 100+ Docker containers, Unbound's caches compete with container memory. At 1.8GB, Unbound pushed the system into 12GB of swap, making it completely unresponsive. 120MB total is more than sufficient for a homelab resolving ~10,000 unique domains.
Thread Alignment¶
num-threads: 2
msg-cache-slabs: 2
rrset-cache-slabs: 2
infra-cache-slabs: 2
key-cache-slabs: 2
Slabs must match num-threads for optimal lock contention. Each thread gets its own slab, reducing mutex overhead on cache lookups.
For most homelabs, 2 threads is sufficient. Use nproc to check your CPU count — don't exceed it.
Socket Optimization¶
so-reuseport: yes
Enables the kernel to distribute incoming UDP queries evenly across threads using SO_REUSEPORT. Without this, all queries hit thread 0 and other threads sit idle. This setting roughly doubles throughput on multi-thread configurations.
EDNS Buffer Size¶
edns-buffer-size: 1232
Per the DNS Flag Day recommendation. Prevents UDP fragmentation which causes packet loss on some networks. The default (4096) can cause issues with routers that fragment large UDP packets.
Upstream RTT Tracking¶
infra-cache-numhosts: 10000
Unbound tracks the round-trip time (RTT) to upstream servers to make smart routing decisions. The default (10,000) is sufficient. This matters more for recursive resolution but still helps with DoT forwarder selection.
Stale Cache Serving — The Key Setting¶
This is the single most impactful optimization for real-time applications:
serve-expired: yes
serve-expired-ttl: 3600 # (1)
serve-expired-client-timeout: 1800 # (2)
- Maximum age of expired entries to serve. After 1 hour past TTL expiry, the entry is discarded rather than served stale.
- If upstream response takes longer than 1.8 seconds, serve the stale entry. In practice, stale is served in ~0ms because upstream DoT takes ~40ms — this is a safety net for outages.
How It Works¶
Without serve-expired-client-timeout:
Client query → Cache expired → Wait for upstream (40-200ms) → Return fresh answer
With serve-expired-client-timeout: 1800 (1.8 seconds):
Client query → Cache expired → Return stale answer IMMEDIATELY (0ms)
→ Refresh from upstream in background
The 1800 value means: if the upstream hasn't responded within 1.8 seconds, serve the stale cache entry. In practice, the stale entry is served within milliseconds because the upstream DoT query takes ~40ms.
Why This Matters for Real-Time Apps¶
- Google Meet / Zoom: DNS lookups happen during ICE candidate gathering and STUN/TURN server resolution. A 200ms DNS delay causes a visible video stutter.
- Gaming (LoL, Valorant): Game servers do DNS lookups for matchmaking, chat, and telemetry. A stalled DNS query causes a 2-5 second disconnect.
- Boot flooding: When a laptop boots, 200+ DNS queries fire simultaneously. Without stale serving, each unique domain blocks until upstream responds. With it, all cached domains resolve in 0ms.
Combined with AdGuard's Optimistic Cache¶
AdGuard has its own stale-serving mechanism:
cache_optimistic: true
This provides two layers of stale serving: 1. AdGuard returns a stale cached answer to the client immediately 2. Sends the query to Unbound, which also returns a stale answer immediately if it has one 3. Unbound queries upstream over DoT in the background 4. Both caches update when the fresh answer arrives
The client never waits.
AdGuard Performance Settings¶
Cache Size¶
cache_size: 10000000 # 10 MB
cache_ttl_min: 600 # minimum 10 minutes
cache_ttl_max: 86400 # cap at 1 day
The default 4096 entries is far too small for 100+ containers each resolving dozens of domains. At 10 MB, the cache comfortably holds all commonly queried domains without eviction.
cache_ttl_min: 600 forces a minimum 10-minute cache, reducing upstream query volume by ~70% for domains with very short TTLs (like CDNs that set 60-second TTLs). cache_ttl_max: 86400 caps entries at 1 day to prevent serving stale records indefinitely.
Blocked Response TTL¶
blocked_response_ttl: 60
When AdGuard blocks a domain, clients receive a 0.0.0.0 response. With the default TTL of 10 seconds, clients re-query blocked domains every 10 seconds — generating unnecessary load. Setting this to 60 seconds means clients cache the "blocked" answer for a full minute, reducing repeated queries for the same blocked domain by ~6x.
Concurrent Query Handling¶
max_goroutines: 500
upstream_timeout: 5s
max_goroutines controls how many DNS queries AdGuard can process simultaneously. The default (300) can bottleneck during boot flooding when 100+ containers start at once. 500 provides headroom.
upstream_timeout controls how long AdGuard waits for Unbound before failing over to fallback DNS. Reducing from 10s to 5s means faster failover — if Unbound is having issues, clients get answers from the encrypted fallback resolvers within 5 seconds instead of 10.
Safe Browsing & Filter Updates¶
safebrowsing_enabled: true
safebrowsing_cache_size: 4194304 # 4 MB (default 1 MB)
filters_update_interval: 12 # hours (default 24)
Safe Browsing uses AdGuard's own threat intelligence feed to block known malicious and phishing domains, independent of blocklists. Increasing the cache from 1 MB to 4 MB reduces repeated lookups against AdGuard's servers.
Updating blocklists every 12 hours (instead of 24) ensures new threats are blocked faster.
Rate Limiting¶
ratelimit: 0 # disabled
The default ratelimit of 20 queries/second per subnet silently drops queries during bursts — like when 100 containers start simultaneously, or when a laptop boots and fires 200 queries in 2 seconds. Since all clients are on a trusted LAN, disable it entirely.
EDNS Client Subnet¶
edns_client_subnet:
enabled: false
EDNS Client Subnet (ECS) sends a portion of your IP address to upstream DNS servers so they can return geographically optimized results. Disabling it is a privacy trade-off — you lose some CDN optimization but prevent upstream resolvers from learning your subnet.
AAAA (IPv6) Filtering¶
aaaa_disabled: true
If your network doesn't use IPv6, this halves the number of upstream queries. Every DNS lookup normally generates two queries (A + AAAA). Disabling AAAA reduces load and speeds up resolution.
Measuring Performance¶
Cached vs Uncached Latency¶
## First query (cold cache) — hits upstream via DoT
dig @127.0.0.1 example.com +timeout=5 | grep "Query time"
## Expected: ~40-80ms
## Second query (cached) — served from Unbound cache
dig @127.0.0.1 example.com +timeout=5 | grep "Query time"
## Expected: 0ms
Cache Hit Rate¶
Check AdGuard's dashboard at http://adguard.server.lan:8091 → Statistics. A healthy setup shows 70-90% cache hit rate after warmup.
Verify DoT Is Working¶
## Check Unbound is using TLS connections
ss -tnp | grep :853
## Should show ESTAB connections to 194.242.2.2 and 9.9.9.9
Performance Summary¶
| Setting | Before | After | Impact |
|---|---|---|---|
| Unbound caches | 1.8GB | 120MB | Eliminated swap thrashing |
serve-expired-client-timeout |
Not set | 1800ms | 0ms stale responses |
cache_optimistic |
false | true | Double-layer stale serving |
cache_size |
4096 | 10 MB | Large cache, no eviction pressure |
cache_ttl_min |
0 | 600s | 70% fewer upstream queries for short-TTL domains |
cache_ttl_max |
unlimited | 86400s | Prevents indefinitely stale records |
blocked_response_ttl |
10s | 60s | 6x fewer re-queries for blocked domains |
upstream_timeout |
10s | 5s | Faster failover to fallback DNS |
max_goroutines |
300 | 500 | Handles boot flooding from 100+ containers |
ratelimit |
20 qps | 0 (off) | No dropped queries during bursts |
so-reuseport |
no | yes | Even thread utilization |
prefetch |
no | yes | Popular domains stay cached |
aaaa_disabled |
false | true | 50% fewer upstream queries |
safebrowsing_enabled |
false | true | Blocks malicious/phishing domains |
filters_update_interval |
24h | 12h | Faster blocklist updates |
fallback_dns |
empty | Mullvad/Quad9/dns0.eu (DoT) | Encrypted failover if Unbound is down |
Troubleshooting¶
Every issue documented here was encountered in production. Each section includes the symptoms, root cause, diagnosis steps, and fix.
dig @127.0.0.1 Times Out but LAN IP Works¶
Symptoms:
$ dig @127.0.0.1 google.com +timeout=3
;; communications error to 127.0.0.1#53: timed out
;; no servers could be reached
$ dig @<YOUR_SERVER_IP> google.com +timeout=3
142.251.220.206 # Works!
Root cause: AdGuard Home's allowed_clients does not include 127.0.0.0/8. When a query arrives from 127.0.0.1, AdGuard refuses it because the source IP isn't in the allowlist. TCP queries get REFUSED; UDP queries are silently dropped.
Diagnosis:
## TCP shows REFUSED (the clue)
dig @127.0.0.1 google.com +tcp +timeout=3
## status: REFUSED
## Check allowed_clients
docker exec adguardhome grep -A5 'allowed_clients' /opt/adguardhome/conf/AdGuardHome.yaml
Fix:
docker exec adguardhome sed -i 's/ allowed_clients:/ allowed_clients:\n - 127.0.0.0\/8/' \
/opt/adguardhome/conf/AdGuardHome.yaml
docker restart adguardhome
Unbound Returns SERVFAIL on Everything¶
Symptoms: Every query returns status: SERVFAIL. Unbound is running and listening.
Possible causes (check in order):
1. use-caps-for-id: yes¶
This feature randomizes query name casing to detect DNS spoofing. Many authoritative servers don't preserve case, causing Unbound to treat every response as spoofed.
grep 'use-caps-for-id' /etc/unbound/unbound.conf.d/adguard.conf
## If yes, change to no
Logs will show module_event_capsfail repeatedly.
2. harden-referral-path: yes¶
This does extra queries to validate the referral chain. If any validation query fails, the entire resolution fails. Remove it entirely — the security benefit is minimal for a forwarder.
3. DNSSEC trust anchor priming failure¶
info: failed to prime trust anchor -- could not fetch DNSKEY rrset
If Unbound can't validate the root DNSSEC key (common with ISP hijacking), and val-permissive-mode: no, all queries fail. Set val-permissive-mode: yes to log failures without blocking.
4. subnetcache module interference¶
On Ubuntu 24.04, Unbound 1.19.2 has the subnetcache module compiled in. It loads automatically even without send-client-subnet in the config. Combined with serve-expired and prefetch, it produces warnings:
warning: subnetcache: serve-expired is set but not working for data originating from the subnet module cache
This doesn't break forwarding but caused issues with recursive resolution. The module auto-loads — don't try to exclude it with module-config: "validator iterator" as this can cause worse problems on some builds.
ISP DNS Hijacking Breaks Recursive Resolution¶
Symptoms: Unbound forwarding to 1.1.1.1 works, but recursive resolution (no forward-zone) times out on every root server query.
Root cause: Your ISP transparently redirects all port 53 traffic (UDP and TCP) to their own DNS servers. When Unbound sends a non-recursive query (RD=0) to a root server, the ISP's proxy intercepts it. The proxy can't handle non-recursive queries, so it drops them or returns garbage.
How to confirm:
## This works (your shell sends RD=1, ISP resolver handles it)
dig @198.41.0.4 google.com +short +timeout=3
## Returns an IP — but you're NOT actually talking to the root server
## This is what Unbound sends (RD=0) — fails because ISP can't handle it
dig @198.41.0.4 . NS +norec +timeout=3
## Timeout or SERVFAIL
The definitive test from RIPE Labs:
dig @198.41.0.4 hostname.bind CH TXT +timeout=3
## If hijacked: timeout, SERVFAIL, or wrong answer
## If not hijacked: returns the root server's hostname
Fix: Use DNS-over-TLS forwarding instead of recursive resolution. DoT uses port 853, which ISPs don't hijack:
forward-zone:
name: "."
forward-tls-upstream: yes
forward-addr: 194.242.2.2@853#dns.mullvad.net
ISPs known to hijack: Many ISPs in Asia ( China, India, Indonesia, Brazil, Turkey. If you're on one of these, recursive resolution will not work without a VPN tunnel.
Unbound Swap Thrashing Causes Cascading DNS Outage¶
Symptoms: After 3-7 days, the server becomes unresponsive. SSH sessions freeze. All containers lose DNS. Server swap usage is 12GB+.
Root cause: Unbound's caches were set too large (1GB rrset + 512MB msg + 256MB key = 1.8GB). With malloc overhead, actual usage is ~2.5x the configured value (~4.5GB). On a 16GB server running 100+ containers, this pushes the system into heavy swap usage. Unbound's cache access patterns cause constant page faults, which cascade into I/O wait, which blocks DNS responses, which causes all containers to retry, which increases load further.
Fix: Right-size the caches. For a homelab with ~10,000 unique domains:
rrset-cache-size: 64m
msg-cache-size: 32m
key-cache-size: 16m
neg-cache-size: 8m
Monitor:
## Check Unbound memory
systemctl status unbound | grep Memory
## Should show ~20-30MB, peak ~50MB. If it exceeds 200MB, caches are too large.
## Check system swap
free -m | grep Swap
## Swap used should be under 1GB for healthy operation
GL.iNet Router: DNS Stops Working After Configuration¶
Symptoms: After changing the router's DNS settings, ping google.com returns bad address on the router and all clients lose internet.
Trap 1: force_dns='1'¶
GL.iNet routers have force_dns='1' by default. This creates iptables DNAT rules that redirect ALL port 53 traffic passing through the router to dnsmasq. If you set DHCP option 6 to <YOUR_SERVER_IP>, clients try to reach your DNS server, but the router intercepts the traffic → dnsmasq tries to forward → gets intercepted → DNS loop → total failure.
Fix: Always disable before changing DNS:
uci set dhcp.@dnsmasq[0].force_dns='0'
uci commit dhcp
/etc/init.d/dnsmasq restart
Trap 2: Setting noresolv and WAN DNS¶
Setting noresolv='1' and pointing the router's upstream to <YOUR_SERVER_IP> sounds logical but is fragile. If AdGuard/Unbound restarts, the router itself loses DNS, which can prevent dnsmasq from resolving anything — including the path back to <YOUR_SERVER_IP> if it goes through DNS.
Safe approach: Only use DHCP option 6. Leave the router's own DNS untouched (ISP DNS). This way the router always works, and only clients use your privacy DNS.
## SAFE: Only affects clients
uci add_list dhcp.lan.dhcp_option='6,<YOUR_SERVER_IP>'
uci commit dhcp
/etc/init.d/dnsmasq restart
## DANGEROUS: Don't do this
## uci set dhcp.@dnsmasq[0].noresolv='1'
## uci add_list dhcp.@dnsmasq[0].server='<YOUR_SERVER_IP>'
Trap 3: /etc/init.d/network restart¶
Never restart the router's network stack when testing DNS changes. It takes down all interfaces briefly, which disconnects your SSH session and can leave the router in a bad state. Only restart dnsmasq.
systemd Watchdog Kills Unbound Every 60 Seconds¶
Symptoms: Monitoring alerts show Unbound entering failed state every 60 seconds:
unbound.service: Failed with result 'watchdog'
unbound.service: Killing process with signal SIGABRT
Root cause: WatchdogSec=60 in the systemd override requires Unbound to send sd_notify(WATCHDOG=1) pings. Unbound does not implement systemd watchdog. After 60 seconds without a ping, systemd kills it.
Fix:
sudo tee /etc/systemd/system/unbound.service.d/override.conf << 'EOF'
[Service]
LimitNOFILE=65536
LimitNPROC=512
Restart=on-failure
RestartSec=5
EOF
sudo systemctl daemon-reload
sudo systemctl restart unbound
AdGuard Container Takes 10+ Seconds to Start¶
Symptoms: After docker restart adguardhome, port 53 returns "connection refused" for 10-15 seconds. Scripts that test immediately after restart fail.
Root cause: AdGuard Home enumerates all Docker veth interfaces on startup (host networking mode). With 100+ containers, this takes 7-15 seconds before the DNS listener starts.
Workaround: When scripting, poll instead of using a fixed sleep:
docker restart adguardhome
for i in $(seq 1 12); do
r=$(dig @127.0.0.1 google.com +short +timeout=3 2>&1 | head -1)
if [[ "$r" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Ready after $((i*5))s"
break
fi
sleep 5
done
resolv.conf Resets After Reboot¶
Symptoms: After rebooting the server, cat /etc/resolv.conf shows the ISP/DHCP nameserver instead of 127.0.0.1. All containers that use the host resolver fail.
Root cause: Netplan or DHCP client overwrites /etc/resolv.conf on boot.
Fix: Create a netplan override:
sudo tee /etc/netplan/99-dns-override.yaml << 'EOF'
network:
version: 2
ethernets:
<YOUR_INTERFACE>:
nameservers:
addresses: [127.0.0.1]
dhcp4-overrides:
use-dns: false
EOF
sudo chmod 600 /etc/netplan/99-dns-override.yaml
sudo netplan apply
Quick Diagnostic Commands¶
## Full stack test (run all at once)
echo "=== resolv.conf ===" && cat /etc/resolv.conf && \
echo "=== Unbound ===" && dig @127.0.0.1 -p 5335 google.com +short +timeout=5 && \
echo "=== AdGuard localhost ===" && dig @127.0.0.1 google.com +short +timeout=5 && \
echo "=== AdGuard LAN ===" && dig @<YOUR_SERVER_IP> google.com +short +timeout=5 && \
echo "=== System ===" && ping -c 1 google.com | head -2
## Check Unbound is using DoT
ss -tnp | grep :853
## Check Unbound memory
systemctl status unbound | grep Memory
## Check AdGuard is running
docker ps --filter name=adguardhome --format '{{.Status}}'
## Check cache hit rate
dig @127.0.0.1 google.com +timeout=5 | grep "Query time"
## Second run should be 0ms
Resources¶
Official Documentation¶
| Resource | URL | Notes |
|---|---|---|
| Unbound docs | unbound.docs.nlnetlabs.nl | Official NLnet Labs documentation |
| Unbound man page | nlnetlabs.nl/documentation/unbound/unbound.conf | Every config option explained |
| AdGuard Home wiki | github.com/AdguardTeam/AdGuardHome/wiki | Setup, encryption, configuration |
| AdGuard encryption guide | AdGuardHome Encryption | DoH/DoT setup for AdGuard |
| DNS Flag Day | dnsflagday.net | EDNS buffer size recommendations |
Guides and Tutorials¶
| Resource | URL | Notes |
|---|---|---|
| Pi-hole + Unbound guide | docs.pi-hole.net/guides/dns/unbound | Best reference Unbound config (applies to AdGuard too) |
| Calomel Unbound tutorial | calomel.org/unbound_dns.html | Deep performance tuning guide |
| AdGuard + Unbound + WireGuard | github.com/trinib/AdGuard-WireGuard-Unbound-DNScrypt | Comprehensive self-hosted security guide |
| Unbound gaming tuning | SNBForums thread | Latency optimization for gaming |
Privacy DNS Providers¶
Recommended (No-Log, Non-Five-Eyes)¶
| Provider | DoT Address | Logging | Jurisdiction | Extras |
|---|---|---|---|---|
| Mullvad DNS | 194.242.2.2@853#dns.mullvad.net |
None (Cure53 audited) | Sweden | Also offers ad-blocking variant |
| Mullvad adblock | 194.242.2.3@853#adblock.dns.mullvad.net |
None | Sweden | Blocks ads + trackers |
| Quad9 | 9.9.9.9@853#dns.quad9.net |
None | Switzerland | Non-profit, malware blocking |
| Quad9 secondary | 149.112.112.112@853#dns.quad9.net |
None | Switzerland | Anycast redundancy |
| Quad9 unfiltered | 9.9.9.10@853#dns.quad9.net |
None | Switzerland | No malware filtering |
| dns0.eu zero | 193.110.81.254@853#zero.dns0.eu |
No personal data | France/EU-only | GDPR-hardened, EU infrastructure only |
Other Providers (Use With Caution)¶
| Provider | DoT Address | Logging | Jurisdiction | Notes |
|---|---|---|---|---|
| Cloudflare | 1.1.1.1@853#cloudflare-dns.com |
Partial (24h) | USA | Fast but Five Eyes jurisdiction |
8.8.8.8@853#dns.google |
Yes | USA | Not recommended for privacy |
AdGuard Home Fallback DNS¶
These are the encrypted fallback resolvers used by AdGuard Home when Unbound is unavailable:
| Priority | Provider | URL | Why |
|---|---|---|---|
| 1st | Mullvad DNS | tls://doh.mullvad.net |
Best anonymity posture, audited |
| 2nd | Quad9 | tls://dns.quad9.net |
Swiss non-profit, reliable |
| 3rd | dns0.eu zero | tls://zero.dns0.eu |
EU-native, no filtering overlap |
ISP DNS Hijacking Detection¶
| Resource | URL | Notes |
|---|---|---|
| RIPE Labs detection guide | labs.ripe.net | Definitive detection methodology |
| XDA DNS bypass guide | xda-developers.com | Fixing DNS filter bypass |
Blocklists (AdGuard Home)¶
These are the 18 blocklists configured in our setup, organized by category:
Core (Ads & Trackers)¶
| List | URL | Notes |
|---|---|---|
| AdGuard DNS filter | https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt |
AdGuard's baseline list |
| AdAway Default | https://adguardteam.github.io/HostlistsRegistry/assets/filter_2.txt |
Mobile ads |
| OISD Full | https://big.oisd.nl/ |
All-in-one, community-curated, low false positives |
| HaGeZi Ultimate | https://raw.githubusercontent.com/hagezi/dns-blocklists/main/domains/ultimate.txt |
Most aggressive multi-source list |
| ppfeufer | https://github.com/ppfeufer/adguard-filter-list/raw/master/blocklist |
Additional coverage |
Security (Malware, Phishing, Threats)¶
| List | URL | Notes |
|---|---|---|
| HaGeZi Threat Intelligence | https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/tif.txt |
Aggregated threat intel (malware, phishing, C2) |
| Phishing Army Extended | https://phishing.army/download/phishing_army_blocklist_extended.txt |
Comprehensive phishing domains |
| URLhaus Malware | https://malware-filter.gitlab.io/malware-filter/urlhaus-filter-agh.txt |
Abuse.ch malware feed |
| DandelionSprout Anti-Malware | https://raw.githubusercontent.com/DandelionSprout/adfilt/master/Alternate%20versions%20Anti-Malware%20List/AntiMalwareAdGuardHome.txt |
Well-maintained anti-malware |
Privacy & Tracking¶
| List | URL | Notes |
|---|---|---|
| EasyPrivacy | https://easylist.to/easylist/easyprivacy.txt |
Classic tracking protection |
| AdGuard Tracking Protection | https://raw.githubusercontent.com/AdguardTeam/FiltersRegistry/master/filters/filter_3_Spyware/filter.txt |
AdGuard's own tracker list |
| HaGeZi DoH/VPN/Proxy Bypass | https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/doh-vpn-proxy-bypass.txt |
Prevents apps from bypassing your DNS |
Device Telemetry¶
| List | URL | Notes |
|---|---|---|
| Perflyst Smart-TV | https://raw.githubusercontent.com/Perflyst/PiHoleBlocklist/master/SmartTV.txt |
Samsung, LG, Vizio telemetry |
| Perflyst Android Tracking | https://raw.githubusercontent.com/Perflyst/PiHoleBlocklist/master/android-tracking.txt |
Android-specific trackers |
| HaGeZi Native Amazon | https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/native.amazon.txt |
Amazon/Alexa telemetry |
Annoyances & Cryptomining¶
| List | URL | Notes |
|---|---|---|
| AdGuard Annoyances | https://raw.githubusercontent.com/AdguardTeam/FiltersRegistry/master/filters/filter_14_Annoyances/filter.txt |
Cookie banners, popups |
| Fanboy Annoyance | https://secure.fanboy.co.nz/fanboy-annoyance.txt |
Social widgets, popups |
| ZeroDot1 CoinBlocker | https://zerodot1.gitlab.io/CoinBlockerLists/hosts_browser |
Cryptomining/cryptojacking |
Aggressive blocklists may break sites
HaGeZi Ultimate and some security lists are aggressive. They may block Facebook, payment processors, or telemetry required for app functionality. A comprehensive allowlist is configured to keep major apps working (YouTube, Instagram, Facebook, WhatsApp, Spotify, Discord, etc.). Always check AdGuard's query log when a site breaks and whitelist the blocked domain with @@||domain.com^$important.
Related Vault Pages¶
- core-stack — AdGuard Home is part of the core infrastructure stack
- monitoring-stack — Grafana dashboards for DNS metrics