catalog / docker.io/adguard/adguardhome

docker.io/adguard/adguardhome

validated  pin current  tags: v0.107.78

adguardhome — minimum Linux capabilities, DNS-only scope (pihole's
alternative; DHCP/NET_ADMIN out of scope, matching the pihole boundary).
Trims 14 -> 2: cap_add:[DAC_OVERRIDE, NET_BIND_SERVICE].

The clean contrast with pihole: AdGuardHome is a single Go binary that
stays ROOT (no FTL-style file-cap + user drop), so DAC_OVERRIDE covers its
root config/data writes on the fresh rootfs and NET_BIND_SERVICE covers the
privileged :53 (and :80 web) directly from the process caps — NO SETFCAP,
NO SETUID/SETGID, and no-new-privileges STAYS COMPATIBLE (pihole must drop
nnp; AdGuard need not). NET_BIND_SERVICE is scoped to the pinned
ip_unprivileged_port_start=1024 posture. See
criteria/docker.io/adguard/adguardhome.md.
filesystem: read_only:true, tmpfs:[]. AdGuard writes its config to
/opt/adguardhome/conf and its data (query log, stats, filter lists) to
/opt/adguardhome/work — persistent bind-mounts/volumes in production (never
tmpfs). Under a read-only rootfs with those two writable it serves DNS with no
additional tmpfs. /tmp was drop-tested and is NOT required. Derived by drop-test.

Use it

services:
  adguardhome:
    image: docker.io/adguard/adguardhome:v0.107.78
    cap_drop: [ALL]
    cap_add: [DAC_OVERRIDE, NET_BIND_SERVICE]
    security_opt: ["no-new-privileges:true"]
    read_only: true

This profile has multiple dimensions and is applied as a unit. Dimensions can interact — a capability can be required only because of a sibling read-only/tmpfs recommendation — so where they do, the minimum was derived under the sibling dimension's context. See the criteria doc and each dimension's recorded invocation below before applying them separately.

Dimension: capabilities

cap_drop: ALL + cap_add: DAC_OVERRIDE, NET_BIND_SERVICE

toolcontainer-sec-derive 0.7.0
observerdrop-test
validated imagedocker.io/adguard/adguardhome@sha256:1ea34eafe5dc…
validated date2026-07-16
confidencemoderate
validated viadrop-test, ci-smoke
workloadprofiles/workloads/adguardhome.sh
workload sha25624ee57434273…
ig versionv0.51.0

Feature coverage (ledger)

A drop-test proves the minimum for what the workload exercised. Privilege-relevant features it did not drive are listed honestly — using one may need more than this minimum.

featureevidence / reason
drivenDNS filtering/resolutiona local rewrite (csd.test→127.0.0.1) resolves under the derived set — no upstream in the loop
not drivenDHCP serveroptional feature documented to need NET_ADMIN (broadcast/interface handling); not driven — DHCP deployments need NET_ADMIN on top

Drop-test evidence

Each candidate removed in turn, the container restarted, and the workload re-verified.

RemovedVerdictObserved
CHOWNremovablecorrect: install/configure ok, AGH resolves local rewrite on :53
DAC_OVERRIDErequiredcontainer failed to start: 2026/07/16 04:17:05.087064 [fatal] creating DNS data dir at /opt/adguardhome/work/d
FSETIDremovablecorrect: install/configure ok, AGH resolves local rewrite on :53
FOWNERremovablecorrect: install/configure ok, AGH resolves local rewrite on :53
MKNODremovablecorrect: install/configure ok, AGH resolves local rewrite on :53
NET_RAWremovablecorrect: install/configure ok, AGH resolves local rewrite on :53
SETGIDremovablecorrect: install/configure ok, AGH resolves local rewrite on :53
SETUIDremovablecorrect: install/configure ok, AGH resolves local rewrite on :53
SETFCAPremovablecorrect: install/configure ok, AGH resolves local rewrite on :53
SETPCAPremovablecorrect: install/configure ok, AGH resolves local rewrite on :53
NET_BIND_SERVICErequiredcontainer failed to start: exec /opt/adguardhome/AdGuardHome: operation not permitted
SYS_CHROOTremovablecorrect: install/configure ok, AGH resolves local rewrite on :53
KILLremovablecorrect: install/configure ok, AGH resolves local rewrite on :53
AUDIT_WRITEremovablecorrect: install/configure ok, AGH resolves local rewrite on :53

Recorded invocation (run_config)

The minimum is valid for this invocation; a different user:, volume state, or entrypoint can change it.

security_optno-new-privileges:true
sysctlsnet.ipv4.ip_unprivileged_port_start=1024
Workload script (profiles/workloads/adguardhome.sh)
#!/usr/bin/env bash
# Workload exerciser for the adguardhome reference image (DNS-only). Drives the
# REST install/configure (admin + DNS:53 + web:80 in one call), adds a LOCAL
# DNS rewrite (csd.test -> 127.0.0.1) so resolution is answered by AGH itself
# with no upstream in the loop, then asserts nslookup returns 127.0.0.1.
# Required env: ADGUARDHOMECONTAINER (target container name or id).
set -euo pipefail
: "${ADGUARDHOMECONTAINER:?ADGUARDHOMECONTAINER must be set}"
C="${ADGUARDHOMECONTAINER}"
PROBE="${CSD_PROBE_CURL_IMAGE:-curlimages/curl@sha256:c1fe1679c34d9784c1b0d1e5f62ac0a79fca01fb6377cdd33e90473c6f9f9a69}"
sc() { docker run --rm --network "container:${C}" "$PROBE" "$@"; }
deadline=$((SECONDS+45))
while :; do
    code="$(sc -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:3000/ 2>/dev/null)"
    case "$code" in 200|30?) break;; esac
    (( SECONDS >= deadline )) && { echo "setup UI never came up" >&2; exit 1; }
    sleep 2
done
c="$(sc -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST -H 'Content-Type: application/json' -d '{"web":{"ip":"0.0.0.0","port":80},"dns":{"ip":"0.0.0.0","port":53},"username":"csdadmin","password":"CsdProbe-Pw-12345"}' http://localhost:3000/control/install/configure 2>/dev/null)"
[ "$c" = 200 ] || { echo "configure failed: HTTP ${c:-none}" >&2; exit 1; }
r="$(sc -s -o /dev/null -w '%{http_code}' --max-time 10 -u csdadmin:CsdProbe-Pw-12345 -X POST -H 'Content-Type: application/json' -d '{"domain":"csd.test","answer":"127.0.0.1"}' http://localhost:80/control/rewrite/add 2>/dev/null)"
[ "$r" = 200 ] || { echo "rewrite add failed: HTTP ${r:-none}" >&2; exit 1; }
deadline=$((SECONDS+20))
# capture-then-match — never `producer | grep -q` under pipefail (grep's early
# exit SIGPIPEs the producer and a matching response reads as failure).
until grep -qE "Address:[[:space:]]*127\.0\.0\.1$" <<<"$(docker exec "$C" nslookup csd.test 127.0.0.1 2>/dev/null)"; do
    (( SECONDS >= deadline )) && { echo "AGH never resolved the local rewrite" >&2; exit 1; }
    sleep 2
done

Dimension: filesystem

read_only: true, no tmpfs

toolcontainer-sec-derive 0.7.0
observerdrop-test
validated imagedocker.io/adguard/adguardhome@sha256:1ea34eafe5dc…
validated date2026-07-16
confidencehigh
validated viadrop-test, ci-smoke
workloadprofiles/workloads/adguardhome.sh
workload sha25624ee57434273…
ig versionv0.51.0

Drop-test evidence

Each candidate removed in turn, the container restarted, and the workload re-verified.

RemovedVerdictObserved
/tmpremovablecorrect: install/configure ok, AGH resolves local rewrite on :53
Workload script (profiles/workloads/adguardhome.sh)
#!/usr/bin/env bash
# Workload exerciser for the adguardhome reference image (DNS-only). Drives the
# REST install/configure (admin + DNS:53 + web:80 in one call), adds a LOCAL
# DNS rewrite (csd.test -> 127.0.0.1) so resolution is answered by AGH itself
# with no upstream in the loop, then asserts nslookup returns 127.0.0.1.
# Required env: ADGUARDHOMECONTAINER (target container name or id).
set -euo pipefail
: "${ADGUARDHOMECONTAINER:?ADGUARDHOMECONTAINER must be set}"
C="${ADGUARDHOMECONTAINER}"
PROBE="${CSD_PROBE_CURL_IMAGE:-curlimages/curl@sha256:c1fe1679c34d9784c1b0d1e5f62ac0a79fca01fb6377cdd33e90473c6f9f9a69}"
sc() { docker run --rm --network "container:${C}" "$PROBE" "$@"; }
deadline=$((SECONDS+45))
while :; do
    code="$(sc -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:3000/ 2>/dev/null)"
    case "$code" in 200|30?) break;; esac
    (( SECONDS >= deadline )) && { echo "setup UI never came up" >&2; exit 1; }
    sleep 2
done
c="$(sc -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST -H 'Content-Type: application/json' -d '{"web":{"ip":"0.0.0.0","port":80},"dns":{"ip":"0.0.0.0","port":53},"username":"csdadmin","password":"CsdProbe-Pw-12345"}' http://localhost:3000/control/install/configure 2>/dev/null)"
[ "$c" = 200 ] || { echo "configure failed: HTTP ${c:-none}" >&2; exit 1; }
r="$(sc -s -o /dev/null -w '%{http_code}' --max-time 10 -u csdadmin:CsdProbe-Pw-12345 -X POST -H 'Content-Type: application/json' -d '{"domain":"csd.test","answer":"127.0.0.1"}' http://localhost:80/control/rewrite/add 2>/dev/null)"
[ "$r" = 200 ] || { echo "rewrite add failed: HTTP ${r:-none}" >&2; exit 1; }
deadline=$((SECONDS+20))
# capture-then-match — never `producer | grep -q` under pipefail (grep's early
# exit SIGPIPEs the producer and a matching response reads as failure).
until grep -qE "Address:[[:space:]]*127\.0\.0\.1$" <<<"$(docker exec "$C" nslookup csd.test 127.0.0.1 2>/dev/null)"; do
    (( SECONDS >= deadline )) && { echo "AGH never resolved the local rewrite" >&2; exit 1; }
    sleep 2
done

Evidence & provenance

Raw profile YAML
# adguardhome — minimum Linux capabilities, DNS-only scope (pihole's
# alternative; DHCP/NET_ADMIN out of scope, matching the pihole boundary).
# Trims 14 -> 2: cap_add:[DAC_OVERRIDE, NET_BIND_SERVICE].
#
# The clean contrast with pihole: AdGuardHome is a single Go binary that
# stays ROOT (no FTL-style file-cap + user drop), so DAC_OVERRIDE covers its
# root config/data writes on the fresh rootfs and NET_BIND_SERVICE covers the
# privileged :53 (and :80 web) directly from the process caps — NO SETFCAP,
# NO SETUID/SETGID, and no-new-privileges STAYS COMPATIBLE (pihole must drop
# nnp; AdGuard need not). NET_BIND_SERVICE is scoped to the pinned
# ip_unprivileged_port_start=1024 posture. See
# criteria/docker.io/adguard/adguardhome.md.
# filesystem: read_only:true, tmpfs:[]. AdGuard writes its config to
# /opt/adguardhome/conf and its data (query log, stats, filter lists) to
# /opt/adguardhome/work — persistent bind-mounts/volumes in production (never
# tmpfs). Under a read-only rootfs with those two writable it serves DNS with no
# additional tmpfs. /tmp was drop-tested and is NOT required. Derived by drop-test.
#
schema_version: "1.6"
image: docker.io/adguard/adguardhome
reference_url: https://tmatens.github.io/container-security-profiles/profiles/docker.io/adguard/adguardhome.html
applies_to:
  tags: ["v0.107.78"]
status: validated
dimensions:
  capabilities:
    cap_drop: [ALL]
    cap_add: [DAC_OVERRIDE, NET_BIND_SERVICE]
    derivation:
      tool: container-sec-derive
      tool_version: "0.7.0"
      sidecar_schema_version: "1.5"
      observer: drop-test
      validated_image: docker.io/adguard/adguardhome@sha256:1ea34eafe5dc691007946e8eaab7bf46b0de9412f39213d8c06e48b53bf9a6c5
      validated_date: "2026-07-16"
      duration_seconds: 300
      confidence: moderate
      workload: profiles/workloads/adguardhome.sh
      workload_sha256: "24ee5743427395b4658a736f7eb0adca366ddfeaf7002b3baf784d5a745bb004"
      validated_via: [drop-test, ci-smoke]
      observation_backend:
        ig_version: v0.51.0
        gadgets: []
      run_config:
        user: ""
        command: []
        entrypoint: ""
        network: ""
        pid: ""
        devices: []
        security_opt: ["no-new-privileges:true"]
        sysctls: ["net.ipv4.ip_unprivileged_port_start=1024"]
        mounts: []
        env: []
      features:
        - name: "DNS filtering/resolution"
          driven: true
          why: "a local rewrite (csd.test→127.0.0.1) resolves under the derived set — no upstream in the loop"
        - name: "DHCP server"
          driven: false
          why: "optional feature documented to need NET_ADMIN (broadcast/interface handling); not driven — DHCP deployments need NET_ADMIN on top"
      drop_test:
        checks:
          - {removed: CHOWN, required: false, observed: "correct: install/configure ok, AGH resolves local rewrite on :53"}
          - {removed: DAC_OVERRIDE, required: true, observed: "container failed to start: 2026/07/16 04:17:05.087064 [fatal] creating DNS data dir at /opt/adguardhome/work/d"}
          - {removed: FSETID, required: false, observed: "correct: install/configure ok, AGH resolves local rewrite on :53"}
          - {removed: FOWNER, required: false, observed: "correct: install/configure ok, AGH resolves local rewrite on :53"}
          - {removed: MKNOD, required: false, observed: "correct: install/configure ok, AGH resolves local rewrite on :53"}
          - {removed: NET_RAW, required: false, observed: "correct: install/configure ok, AGH resolves local rewrite on :53"}
          - {removed: SETGID, required: false, observed: "correct: install/configure ok, AGH resolves local rewrite on :53"}
          - {removed: SETUID, required: false, observed: "correct: install/configure ok, AGH resolves local rewrite on :53"}
          - {removed: SETFCAP, required: false, observed: "correct: install/configure ok, AGH resolves local rewrite on :53"}
          - {removed: SETPCAP, required: false, observed: "correct: install/configure ok, AGH resolves local rewrite on :53"}
          - {removed: NET_BIND_SERVICE, required: true, observed: "container failed to start: exec /opt/adguardhome/AdGuardHome: operation not permitted"}
          - {removed: SYS_CHROOT, required: false, observed: "correct: install/configure ok, AGH resolves local rewrite on :53"}
          - {removed: KILL, required: false, observed: "correct: install/configure ok, AGH resolves local rewrite on :53"}
          - {removed: AUDIT_WRITE, required: false, observed: "correct: install/configure ok, AGH resolves local rewrite on :53"}
  filesystem:
    read_only: true
    tmpfs: []
    derivation:
      tool: container-sec-derive
      tool_version: "0.7.0"
      sidecar_schema_version: "1.5"
      observer: drop-test
      validated_image: docker.io/adguard/adguardhome@sha256:1ea34eafe5dc691007946e8eaab7bf46b0de9412f39213d8c06e48b53bf9a6c5
      validated_date: "2026-07-16"
      duration_seconds: 3
      confidence: high
      workload: profiles/workloads/adguardhome.sh
      workload_sha256: "24ee5743427395b4658a736f7eb0adca366ddfeaf7002b3baf784d5a745bb004"
      validated_via: [drop-test, ci-smoke]
      observation_backend:
        ig_version: v0.51.0
        gadgets: []
      run_config:
        user: ""
        command: []
        entrypoint: ""
        network: ""
        pid: ""
        devices: []
        security_opt: []
        mounts: []
        env: []
      drop_test:
        checks:
          - {removed: /tmp, required: false, observed: "correct: install/configure ok, AGH resolves local rewrite on :53"}