Skip to content

DNS Privacy Stack — Zero to Hero

Ubuntu Unbound AdGuard

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

  1. 01-fundamentals — What each component does and why you need it
  2. 02-architecture — How traffic flows through the stack
  3. 03-setup — Complete installation from scratch
  4. 04-optimization — Performance tuning for real-time apps
  5. 05-troubleshooting — Every pain point and how to fix it
  6. 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.net stay 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
  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.


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)
  1. Maximum age of expired entries to serve. After 1 hour past TTL expiry, the entry is discarded rather than served stale.
  2. 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

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
Google 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.