docker.io/pihole/pihole
validated pin current tags: 2026.07.2
pihole — minimum Linux capabilities, derived by drop-test against the default invocation, DNS-ONLY SCOPE (DHCP mode needs NET_ADMIN and is out of scope). This profile trims the full Docker default cap set 14 -> 6 — and the finding the coverage queue predicted: upstream docs suggest NET_RAW / NET_ADMIN / SYS_NICE, and NONE of them are load-bearing on the DNS path. capabilities: cap_drop:[ALL] + cap_add:[CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETFCAP, SETGID, SETUID]. The architecture is the catalog's fourth privilege shape: the ROOT entrypoint provisions /etc/pihole (CHOWN/DAC_OVERRIDE) and SETCAPs the pihole-FTL binary (SETFCAP — a rare, honest grant), FTL then starts as the pihole user (SETGID/SETUID) and receives NET_BIND_SERVICE for the privileged :53 via those FILE capabilities. TWO HARD SCOPE CONDITIONS: 1. NO no-new-privileges. Measured incompatibility: nnp blocks file-cap acquisition on exec, so under the pinned privileged-port sysctl an nnp'd pihole can NEVER listen on :53. This is the only catalog profile where nnp must be omitted — a real architectural property, not an oversight. 2. NET_BIND_SERVICE is scoped to the pinned net.ipv4.ip_unprivileged_port_start=1024 posture (docker defaults 0). See criteria/docker.io/pihole/pihole.md. filesystem: read_only:false (a derived NEGATIVE result). pihole cannot run under a read-only rootfs as shipped: its entrypoint chowns /macvendor.db and sed-rewrites /crontab.txt at the FILESYSTEM ROOT, and the FTL auto-update writes /usr/bin/pihole-FTL — none relocatable to a tmpfs/volume. Even with generous tmpfs on the config/runtime dirs the baseline fails. See criteria.
Use it
services:
pihole:
image: docker.io/pihole/pihole:2026.07.2
cap_drop: [ALL]
cap_add: [CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETFCAP, SETGID, SETUID]
read_only: false
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: CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETFCAP, SETGID, SETUID
| tool | container-sec-derive 0.7.0 |
|---|---|
| observer | drop-test |
| validated image | docker.io/pihole/pihole@sha256:f7d1be836e3b… |
| validated date | 2026-07-16 |
| confidence | moderate |
| validated via | drop-test, ci-smoke |
| workload | profiles/workloads/pihole.sh |
| workload sha256 | d1757e654379… |
| ig version | v0.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.
| feature | evidence / reason | |
|---|---|---|
| driven | DNS filtering (FTL) | pi.hole is answered by FTL itself under the derived set — no upstream in the loop |
| not driven | DHCP server | documented conditional cap NET_ADMIN; not driven |
| not driven | NTP client | documented conditional cap SYS_TIME; not driven |
| not driven | priority tuning (SYS_NICE) | documented conditional cap; not driven |
Drop-test evidence
Each candidate removed in turn, the container restarted, and the workload re-verified.
| Removed | Verdict | Observed |
|---|---|---|
CHOWN | required | container exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s |
DAC_OVERRIDE | required | container exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s |
FSETID | removable | correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root) |
FOWNER | removable | correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root) |
MKNOD | removable | correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root) |
NET_RAW | removable | correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root) |
SETGID | required | container exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s |
SETUID | required | container exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s |
SETFCAP | required | container exited: If you are seeing this error, please set the environment variable 'DNSMASQ_USER' to the value 'root' |
SETPCAP | removable | correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root) |
NET_BIND_SERVICE | required | FTL never listened on 53 (file-cap NET_BIND_SERVICE draws from the bounding set) |
SYS_CHROOT | removable | correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root) |
KILL | removable | correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root) |
AUDIT_WRITE | removable | correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root) |
Recorded invocation (run_config)
The minimum is valid for this invocation; a different user:, volume state, or entrypoint can change it.
| sysctls | net.ipv4.ip_unprivileged_port_start=1024 |
|---|---|
| env | FTLCONF_webserver_api_password |
Workload script (profiles/workloads/pihole.sh)
#!/usr/bin/env bash
# Workload exerciser for the pihole reference image (DNS-only scope).
#
# Real DNS resolution with no external dependence: dig the `pi.hole` name,
# which pihole-FTL answers authoritatively itself — deterministic, no
# upstream reachability in the loop. Probes exec as --user pihole using the
# in-image dig (a root probe pollutes a caps minimum). The non-root FTL uid
# assert lives in the drop-test correctness check.
#
# Required env: PIHOLECONTAINER (target container name or id).
set -euo pipefail
: "${PIHOLECONTAINER:?PIHOLECONTAINER must be set}"
C="${PIHOLECONTAINER}"
deadline=$((SECONDS + 90))
# capture-then-match — `docker logs | grep -q` under pipefail SIGPIPEs the
# producer once the log outgrows the pipe buffer, turning a match into failure.
until grep -q "listening on .* port 53" <<<"$(docker logs "$C" 2>&1)"; do
(( SECONDS >= deadline )) && { echo "FTL never listened on 53" >&2; exit 1; }
sleep 2
done
ans="$(docker exec --user pihole "$C" dig +short +time=3 +tries=2 @127.0.0.1 pi.hole 2>/dev/null)"
if [ -z "$ans" ]; then
echo "dig pi.hole returned no answer" >&2
exit 1
fi
Dimension: filesystem
read_only: false, no tmpfs
| tool | container-sec-derive 0.7.0 |
|---|---|
| observer | drop-test |
| validated image | docker.io/pihole/pihole@sha256:f7d1be836e3b… |
| validated date | 2026-07-16 |
| confidence | high |
| validated via | drop-test, ci-smoke |
| workload | profiles/workloads/pihole.sh |
| workload sha256 | d1757e654379… |
| ig version | v0.51.0 |
Drop-test evidence
Each candidate removed in turn, the container restarted, and the workload re-verified.
| Removed | Verdict | Observed |
|---|---|---|
read_only-rootfs | required | read-only rootfs is INFEASIBLE: the entrypoint chowns /macvendor.db and sed-rewrites /crontab.txt at the FILESYSTEM ROOT, and the FTL auto-update writes /usr/bin/pihole-FTL - none relocatable to a tmpfs or volume (you cannot tmpfs / or /usr/bin). Verified under pihole's published cap set with generous tmpfs on /etc/pihole,/etc/dnsmasq.d,/run,/tmp,/var/log; the baseline still fails. Reproducer: csd testdata/drop-test/pihole-fs.yaml (exits 3). |
Recorded invocation (run_config)
The minimum is valid for this invocation; a different user:, volume state, or entrypoint can change it.
| env | FTLCONF_webserver_api_password |
|---|
Workload script (profiles/workloads/pihole.sh)
#!/usr/bin/env bash
# Workload exerciser for the pihole reference image (DNS-only scope).
#
# Real DNS resolution with no external dependence: dig the `pi.hole` name,
# which pihole-FTL answers authoritatively itself — deterministic, no
# upstream reachability in the loop. Probes exec as --user pihole using the
# in-image dig (a root probe pollutes a caps minimum). The non-root FTL uid
# assert lives in the drop-test correctness check.
#
# Required env: PIHOLECONTAINER (target container name or id).
set -euo pipefail
: "${PIHOLECONTAINER:?PIHOLECONTAINER must be set}"
C="${PIHOLECONTAINER}"
deadline=$((SECONDS + 90))
# capture-then-match — `docker logs | grep -q` under pipefail SIGPIPEs the
# producer once the log outgrows the pipe buffer, turning a match into failure.
until grep -q "listening on .* port 53" <<<"$(docker logs "$C" 2>&1)"; do
(( SECONDS >= deadline )) && { echo "FTL never listened on 53" >&2; exit 1; }
sleep 2
done
ans="$(docker exec --user pihole "$C" dig +short +time=3 +tries=2 @127.0.0.1 pi.hole 2>/dev/null)"
if [ -z "$ans" ]; then
echo "dig pi.hole returned no answer" >&2
exit 1
fi
Evidence & provenance
- Validation criteria — per-image scenarios and pass criteria
- Profile source in the repository
- Version history — every previously published pin, with the full profile as validated against it
Raw profile YAML
# pihole — minimum Linux capabilities, derived by drop-test against the
# default invocation, DNS-ONLY SCOPE (DHCP mode needs NET_ADMIN and is out of
# scope). This profile trims the full Docker default cap set 14 -> 6 — and
# the finding the coverage queue predicted: upstream docs suggest NET_RAW /
# NET_ADMIN / SYS_NICE, and NONE of them are load-bearing on the DNS path.
#
# capabilities: cap_drop:[ALL] + cap_add:[CHOWN, DAC_OVERRIDE,
# NET_BIND_SERVICE, SETFCAP, SETGID, SETUID]. The architecture is the
# catalog's fourth privilege shape: the ROOT entrypoint provisions
# /etc/pihole (CHOWN/DAC_OVERRIDE) and SETCAPs the pihole-FTL binary
# (SETFCAP — a rare, honest grant), FTL then starts as the pihole user
# (SETGID/SETUID) and receives NET_BIND_SERVICE for the privileged :53 via
# those FILE capabilities.
#
# TWO HARD SCOPE CONDITIONS:
# 1. NO no-new-privileges. Measured incompatibility: nnp blocks file-cap
# acquisition on exec, so under the pinned privileged-port sysctl an
# nnp'd pihole can NEVER listen on :53. This is the only catalog profile
# where nnp must be omitted — a real architectural property, not an
# oversight.
# 2. NET_BIND_SERVICE is scoped to the pinned
# net.ipv4.ip_unprivileged_port_start=1024 posture (docker defaults 0).
# See criteria/docker.io/pihole/pihole.md.
# filesystem: read_only:false (a derived NEGATIVE result). pihole cannot run
# under a read-only rootfs as shipped: its entrypoint chowns /macvendor.db and
# sed-rewrites /crontab.txt at the FILESYSTEM ROOT, and the FTL auto-update
# writes /usr/bin/pihole-FTL — none relocatable to a tmpfs/volume. Even with
# generous tmpfs on the config/runtime dirs the baseline fails. See criteria.
#
schema_version: "1.6"
image: docker.io/pihole/pihole
reference_url: https://tmatens.github.io/container-security-profiles/profiles/docker.io/pihole/pihole.html
applies_to:
tags: ["2026.07.2"]
status: validated
dimensions:
capabilities:
cap_drop: [ALL]
cap_add: [CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETFCAP, SETGID, SETUID]
derivation:
tool: container-sec-derive
tool_version: "0.7.0"
sidecar_schema_version: "1.5"
observer: drop-test
validated_image: docker.io/pihole/pihole@sha256:f7d1be836e3bc608b56d82fc9904f5a831cdfbc0dc9c6d58f94e4c985c70038b
validated_date: "2026-07-16"
duration_seconds: 420
confidence: moderate
workload: profiles/workloads/pihole.sh
workload_sha256: "d1757e6543795069f71d3f0fd6cc1c043e48f0ad0a804c9aca2ce69b8c735161"
validated_via: [drop-test, ci-smoke]
observation_backend:
ig_version: v0.51.0
gadgets: []
run_config:
user: ""
command: []
entrypoint: ""
network: ""
pid: ""
devices: []
security_opt: []
sysctls: ["net.ipv4.ip_unprivileged_port_start=1024"]
mounts: []
env: [FTLCONF_webserver_api_password]
features:
- name: "DNS filtering (FTL)"
driven: true
why: "pi.hole is answered by FTL itself under the derived set — no upstream in the loop"
- name: "DHCP server"
driven: false
why: "documented conditional cap NET_ADMIN; not driven"
- name: "NTP client"
driven: false
why: "documented conditional cap SYS_TIME; not driven"
- name: "priority tuning (SYS_NICE)"
driven: false
why: "documented conditional cap; not driven"
drop_test:
checks:
- {removed: CHOWN, required: true, observed: "container exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s"}
- {removed: DAC_OVERRIDE, required: true, observed: "container exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s"}
- {removed: FSETID, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
- {removed: FOWNER, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
- {removed: MKNOD, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
- {removed: NET_RAW, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
- {removed: SETGID, required: true, observed: "container exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s"}
- {removed: SETUID, required: true, observed: "container exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s"}
- {removed: SETFCAP, required: true, observed: "container exited: If you are seeing this error, please set the environment variable 'DNSMASQ_USER' to the value 'root'"}
- {removed: SETPCAP, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
- {removed: NET_BIND_SERVICE, required: true, observed: "FTL never listened on 53 (file-cap NET_BIND_SERVICE draws from the bounding set)"}
- {removed: SYS_CHROOT, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
- {removed: KILL, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
- {removed: AUDIT_WRITE, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
filesystem:
read_only: false
tmpfs: []
derivation:
tool: container-sec-derive
tool_version: "0.7.0"
sidecar_schema_version: "1.5"
observer: drop-test
validated_image: docker.io/pihole/pihole@sha256:f7d1be836e3bc608b56d82fc9904f5a831cdfbc0dc9c6d58f94e4c985c70038b
validated_date: "2026-07-16"
duration_seconds: 12
confidence: high
workload: profiles/workloads/pihole.sh
workload_sha256: "d1757e6543795069f71d3f0fd6cc1c043e48f0ad0a804c9aca2ce69b8c735161"
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: [FTLCONF_webserver_api_password]
drop_test:
checks:
- {removed: read_only-rootfs, required: true, observed: "read-only rootfs is INFEASIBLE: the entrypoint chowns /macvendor.db and sed-rewrites /crontab.txt at the FILESYSTEM ROOT, and the FTL auto-update writes /usr/bin/pihole-FTL - none relocatable to a tmpfs or volume (you cannot tmpfs / or /usr/bin). Verified under pihole's published cap set with generous tmpfs on /etc/pihole,/etc/dnsmasq.d,/run,/tmp,/var/log; the baseline still fails. Reproducer: csd testdata/drop-test/pihole-fs.yaml (exits 3)."}