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¶
Running AdGuard Home locally gives LAN clients network-wide DNS filtering without installing a blocker on each device. Unbound normally resolves public queries through the DNS hierarchy rather than sending them all to one upstream provider, and serves internal records from tracked local-data templates. AdGuard also has public fallback upstreams, so recursive resolution is the normal path, not a guarantee that queries never reach a third-party resolver. GitOps makes the configuration reproducible and auditable.
Compose File¶
- compose.yaml — primary stack definition
- compose.svlazext.yaml — retained Azure DNS VM override; cloud instance currently unavailable
Access¶
| URL | Description |
|---|---|
https://adguard.${DOMAINNAME} |
Web UI (Traefik forward-auth) |
https://adguard-ext.${DOMAINNAME} |
Retained cloud Web UI definition; currently unavailable |
Architecture¶
- Images: adguard/adguardhome, madnuttah/unbound (resolver), redis (DNS cache backend), busybox (template and permission 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["LAN 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
normally go to co-located Unbound for local-data answers or recursive resolution.
The tracked fallback_dns public DoH upstreams cannot supply Unbound's private
records and do not provide a second LAN DNS server.
This is the LAN client path, not the TrueNAS host's bootstrap path. TrueNAS uses the gateway's public upstream plus a small manual infrastructure record set. See the canonical DNS architecture and background and operator configuration.
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)¶
The adguard-unbound service wraps the image startup with /sbin/tini -- /bin/sh -ec. The wrapper uses sed -i on the ephemeral /usr/local/unbound/unbound.conf inside the container to re-enable the conf.d and zones.d include-toplevel directives, then validates that both exact directives are present. Because the shell uses -e, a failed rewrite or validation stops startup. Finally, exec /entrypoint hands control to the image's original entrypoint, preserving its initialization, permission setup, and privilege drop.
Patching the image's ephemeral config instead of bind-mounting a generated or vendored base file avoids the first-deploy bind-mount race and preserves all other upstream defaults. When Renovate updates the image tag or digest, the next container starts from that image's unbound.conf and applies only the two required include changes, so new upstream defaults are not masked by an older persistent copy.
The included Unbound config files contain ${VAR} placeholders for secrets and environment-specific values (domain names, IP addresses). Before the resolver starts, adguard-unbound-init runs config/unbound/envsubst.sh 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; the password is substituted at deploy time |
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 and chowns output to the service identity |
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; patches and validates the image's ephemeral include directives before executing the upstream entrypoint |
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/ and chowns data/work and data/conf to the service identity |
adguard |
AdGuard Home DNS filter — listens on port 53, forwards to Unbound on the frontend network |
Startup Order¶
adguard-unbound-init (completed) ─┐
adguard-redis (healthy) ──────────┴─→ adguard-unbound (healthy) → adguard-unbound-flush (completed) ──┐
adguard-init (completed) ─────────────────────────────────────────────────────────────────────────────┴─→ adguard
Init Containers¶
adguard-unbound-init uses the BusyBox image to run the envsubst script and chown the output directory:
- Capabilities:
CHOWN(transfer ownership) +DAC_OVERRIDE(overwrite existing output files) - Output:
./data/unbound - Ownership: the values declared by
UNBOUND_UIDandUNBOUND_GIDfor the resolver
adguard-init uses the BusyBox image to seed the AdGuard config from the git-tracked source and set ownership on runtime directories:
- Capabilities:
CHOWN(transfer ownership) +DAC_OVERRIDE(traverse previously chowned directories) - Output:
./data/work,./data/conf - Ownership: the AdGuard service account's PUID and PGID
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),DAC_OVERRIDEonly becausesed -imust rewriteunbound.confwithin the image filesystem,SETUID/SETGID(privilege drop to_unbound), andKILL(Tini signal forwarding after the privilege drop)module-config: overridden to"validator cachedb iterator"inserver-overrides.conf(default is"validator iterator") to enable the Redis-backedcachedbmodule
Healthcheck¶
Unbound's healthcheck verifies three things in sequence:
- Reads
control-enablewithunbound-checkconfand requires the valueyes— proves the startup wrapper enabled theconf.dinclude and maderemote-control.confeffective - 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; it does not test DNS resolution. An unhealthy state alone does not cause Docker Engine to restart the container.
Neither check proves client failover or every server-side integration. Use the DNS failure validation before advertising another resolver.
Memory and Recovery¶
- Default limit:
adguard.mem_limitis${MEM_LIMIT:-2048m}(2 GiB). Filter rebuilds require headroom because old and new engines coexist in memory. An explicit, non-emptyMEM_LIMITsupplied to Compose overrides the default. - When changing the memory limit, recreate the AdGuard container on each affected host, verify the effective limit (including any external override), and observe peak memory through a filter refresh. A restart alone does not apply a changed Compose limit.
Restart policy: on-failure with max_attempts: 3 allows at most three automatic retries. Standalone Compose ignores window: 120s; long uptime does not reset the retry count. See Compose restart-policy semantics.
Multi-Server Deployment¶
The NAS-local stack is the current LAN resolver. The Azure VM became unavailable
after its credits were exhausted; compose.svlazext.yaml and the server mapping
remain in the repository, but are not a working backup. The override retains
the cloud instance's distinct Traefik routing labels.
A second local AdGuard is desired, not deployed. It needs a separate failure domain and its own resolving backend, consistent internal records and filtering policy, and validation with either server unavailable. No automatic multi-server DNS synchronisation has been implemented. See second local resolver configuration for DHCP, host DNS, shared data, and host-specific settings.
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 the template init (
docker logs adguard-unbound-init) and resolver startup wrapper (docker logs adguard-unbound), then confirm AdGuard is resolving queries on port 53 - Advertise AdGuard through DHCP on each intended client VLAN, update manual client overrides, and renew leases. Keep TrueNAS host DNS independent of its own DNS containers; follow the current resolver settings, rather than pointing the gateway's WAN DNS at this stack.
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.