Skip to content

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

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/tcp and 53/udp published on the host for DNS resolution
  • Reverse proxy: Traefik with chain-auth@file middleware; monitoring router on the internal monitoring entrypoint 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_UID and UNBOUND_GID for 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 to UNBOUND_UID:UNBOUND_GID, then drops privileges to the internal _unbound user
  • read_only is omitted: the image writes a pidfile and auth-zone data under /usr/local/unbound/ during startup
  • cap_add: CHOWN (entrypoint chown), DAC_OVERRIDE only because sed -i must rewrite unbound.conf within the image filesystem, SETUID / SETGID (privilege drop to _unbound), and KILL (Tini signal forwarding after the privilege drop)
  • module-config: overridden to "validator cachedb iterator" in server-overrides.conf (default is "validator iterator") to enable the Redis-backed cachedb module

Healthcheck

Unbound's healthcheck verifies three things in sequence:

  1. Reads control-enable with unbound-checkconf and requires the value yes — proves the startup wrapper enabled the conf.d include and made remote-control.conf effective
  2. Resolves healthcheck.${DOMAINNAME} — proves Unbound is running, the config was loaded, and envsubst substituted ${DOMAINNAME} correctly
  3. 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_limit is ${MEM_LIMIT:-2048m} (2 GiB). Filter rebuilds require headroom because old and new engines coexist in memory. An explicit, non-empty MEM_LIMIT supplied 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 routing
  • DDNS_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

  1. Create the dataset vm-pool/apps/services/adguard in TrueNAS
  2. Create a svc-app-adguard group (GID 3101) and user (UID 3101) on the TrueNAS host — see Infrastructure for the full procedure
  3. Add the required variables to secret.sops.env — at minimum DOMAINNAME, DDNS_DOMAIN, and the IP_* addresses referenced in the Unbound A-record template
  4. Encrypt the secrets file: sops -e -i services/adguard/secret.sops.env
  5. 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
  6. 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.