Skip to content

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
  1. Port 5335 avoids conflict with AdGuard Home on port 53. AdGuard forwards to Unbound on this port.
  2. Serves expired cache entries instead of returning SERVFAIL when upstream is down. Critical for reliability.
  3. If upstream hasn't responded in 1.8 seconds, serve the stale cache entry immediately. Clients never wait.
  4. Permissive DNSSEC mode logs validation failures but doesn't block responses. Prevents false positives from breaking resolution.
  5. Enables DNS-over-TLS for all queries to upstream servers. All DNS traffic is encrypted on the wire.
  6. @853 specifies the DoT port. #dns.mullvad.net is 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

  1. Open http://<server-ip>:3000 for the setup wizard
  2. Set the web interface to port 8091 (avoid conflicts with Traefik on 443)
  3. Set the DNS listen to 0.0.0.0:53
  4. 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