DNS Privacy Stack — Setup
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.
Previous: 02-architecture | Next: 04-optimization