AdGuard Home¶
AdGuard Home is a network-wide DNS filtering and ad-blocking server. This stack pairs it with an Unbound recursive resolver for privacy-focused full recursive DNS resolution.
Why¶
Most home networks rely on the ISP's default DNS, which offers no filtering, no encryption, and full visibility into every domain you visit. Running AdGuard Home locally gives you network-wide ad and tracker blocking without installing anything on individual devices. Pairing it with Unbound adds full recursive resolution — Unbound walks the DNS hierarchy from the root servers itself, so no third-party resolver ever sees your queries. This gives stronger privacy than DNS-over-TLS forwarding to an upstream provider, because there is no upstream provider. Finally, managing the config via GitOps means the DNS setup is reproducible, auditable, and recovers automatically after a redeploy.
Compose File¶
- compose.yaml — primary stack definition
- compose.svlazext.yaml — Azure DNS VM override (svlazext)
Access¶
| URL | Description |
|---|---|
https://adguard.${DOMAINNAME} |
Web UI (Traefik forward-auth) |
https://adguard-ext.${DOMAINNAME} |
Web UI on svlazext (Azure DNS VM override) |
Architecture¶
- Images: adguard/adguardhome, madnuttah/unbound, redis (DNS cache backend), busybox (init containers)
- User/Group:
3101:3101(svc-app-adguard) — both AdGuard Home and Unbound run under this identity - Networks:
adguard-frontend(bridge,172.30.53.0/24) — Unbound at.2, AdGuard at.3;adguard-backend(internal bridge, no host exposure) — Unbound and Redis only - Ports:
53/tcpand53/udppublished on the host for DNS resolution - Reverse proxy: Traefik with
chain-auth@filemiddleware; monitoring router on the internalmonitoringentrypoint for Gatus health checks
DNS Resolution Flow¶
flowchart LR
Client -->|":53"| AdGuard["AdGuard Home\n(filtering + blocking)"]
AdGuard -->|":5335"| Unbound["Unbound\n(recursive resolver)"]
Unbound -->|"recursive"| Root["Root DNS servers"]
Unbound -.->|"local-data"| Split["Split-horizon\n(internal records)"]
Unbound -.->|"cachedb"| Redis["Redis\n(persistent cache)"]
AdGuard handles DNS filtering and ad blocking. Queries that pass the filter are forwarded to the co-located Unbound instance, which resolves them recursively starting from the root DNS servers. Internal domain names are served from Unbound's local-data records (split-horizon — see below).
Config Management¶
The AdGuard Home configuration file (config/conf/AdGuardHome.yaml) is git-tracked and treated as the source of truth. On every deploy, adguard-init copies it into data/conf/, overwriting any changes made through the web UI. The UI should be considered read-only — any manual UI changes are lost on the next deployment.
Config Template Substitution (Unbound)¶
Unbound config files contain ${VAR} placeholders for secrets and environment-specific values (domain names, IP addresses). The adguard-unbound-init container runs config/unbound/envsubst.sh at deploy time to substitute these placeholders with values from secret.sops.env and writes the processed output to data/unbound/. Unbound then mounts the processed files read-only.
Template files and their purpose:
| Template | Content |
|---|---|
config/unbound/conf.d/a-records.conf |
Local DNS A records for internal hosts (split-horizon) |
config/unbound/conf.d/server-overrides.conf |
Logging, private-domain, split-horizon zone |
config/unbound/zones.d/forward-zones.conf |
Forward zones — empty by default (full recursive resolution from root); reserved for special-case zone overrides |
config/unbound/conf.d/remote-control.conf |
Unbound remote-control settings (mounted directly, no substitution) |
config/unbound/conf.d/cachedb.conf |
cachedb: clause pointing to Redis backend (mounted directly, no substitution) |
The envsubst.sh script verifies that no unresolved ${VAR} placeholders remain after substitution — missing variables in secret.sops.env cause the init container to fail loudly rather than starting Unbound with a broken config.
Services¶
| Container | Role |
|---|---|
adguard-unbound-init |
One-shot init: substitutes ${VAR} placeholders in Unbound config templates, chowns output to 3101:3101 |
adguard-redis |
Ephemeral Redis cache backend for Unbound's cachedb module — cache survives Unbound restarts but is lost on Redis restart |
adguard-unbound |
Recursive DNS resolver (Unbound) — resolves from root DNS servers, local-data for internal names |
adguard-unbound-flush |
One-shot sidecar: flushes Unbound's cache for ${DOMAINNAME} via unbound-control — clears stale internal entries without wiping the Redis external DNS cache |
adguard-init |
One-shot init: copies AdGuardHome.yaml from repo config into data/conf/, chowns data/work and data/conf to 3101:3101 |
adguard |
AdGuard Home DNS filter — listens on port 53, forwards to Unbound on the frontend network |
Startup Order¶
adguard-redis (healthy) ──────────────────────────────────────────────────┐
adguard-unbound-init (completed) ─────────────────────────────────────────┴─→ adguard-unbound (healthy) → adguard-unbound-flush (completed) ──┐
adguard-init (completed) ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┴─→ adguard
Init Containers¶
adguard-unbound-init runs the envsubst script and chowns the output directory:
- Capabilities:
CHOWN(transfer ownership) +DAC_OVERRIDE(overwrite existing output files) - Volumes chown'd:
./data/unbound
adguard-init seeds the AdGuard config from the git-tracked source and sets ownership on runtime directories:
- Capabilities:
CHOWN(transfer ownership) +DAC_OVERRIDE(traverse previously chowned directories) - Volumes chown'd:
./data/work,./data/conf
Unbound Exceptions¶
The adguard-unbound container deviates from the standard hardening baseline:
user:is omitted: the entrypoint starts as root, chowns directories toUNBOUND_UID:UNBOUND_GID, then drops privileges to the internal_unbounduserread_onlyis omitted: the image writes a pidfile and auth-zone data under/usr/local/unbound/during startupcap_add:CHOWN(entrypoint chown),SETUID/SETGID(privilege drop to_unbound)module-config: overridden to"validator cachedb iterator"inserver-overrides.conf(default is"validator iterator") to enable the Redis-backedcachedbmodule
Healthcheck¶
Unbound's healthcheck verifies two things in sequence:
- Resolves
healthcheck.${DOMAINNAME}— proves Unbound is running, the config was loaded, and envsubst substituted${DOMAINNAME}correctly - Resolves
dns.google— proves recursive resolution from root is working
AdGuard's healthcheck is a simple HTTP check against its web UI on port 80.
Multi-Server Deployment¶
AdGuard runs on both the TrueNAS host (svlnas) and the Azure DNS VM (svlazext). The compose.svlazext.yaml override adjusts Traefik labels to use the adguard-ext.${DOMAINNAME} hostname for the external instance.
Secrets¶
Managed via secret.sops.env (SOPS-encrypted, decrypted to .env at deploy time):
DOMAINNAME— base domain for all internal DNS records and Traefik routingDDNS_DOMAIN— dynamic DNS domain (resolved recursively)IP_*— host IP addresses used in Unbound A records (e.g.IP_SVLNAS,IP_SVLAZEXT,IP_HOME)
First-Run Setup¶
- Create the dataset
vm-pool/apps/services/adguardin TrueNAS - Create a
svc-app-adguardgroup (GID 3101) and user (UID 3101) on the TrueNAS host — see Infrastructure for the full procedure - Add the required variables to
secret.sops.env— at minimumDOMAINNAME,DDNS_DOMAIN, and theIP_*addresses referenced in the Unbound A-record template - Encrypt the secrets file:
sops -e -i services/adguard/secret.sops.env - Deploy: the CD script decrypts secrets and brings the stack up. Verify Unbound health first (
docker logs adguard-unbound-init), then confirm AdGuard is resolving queries on port 53 - Point your network's DNS to the host IP running AdGuard (router DHCP settings or per-device)
Upgrade Notes¶
No special upgrade procedures are required for this stack. AdGuard Home and Unbound handle schema and data migrations automatically on startup. Image updates are managed by Renovate via digest-pinning PRs.