docker.io/netdata/netdata
validated pin current tags: v2.10.3
netdata capability minimum — derived by drop-test (drop-a-cap / restart / verify), the authoritative source for cap_add. drop-test covers the full container lifetime, so it captures the startup-only caps SETUID/SETGID (the root->netdata privilege drop) that runtime observation records as "unused." Dropping those leaves netdata healthy while silently running as root. SYS_ADMIN is removable — the image ships no ebpf.plugin, so ebpf=yes is a no-op. It is removable EVEN for per-container network-interface metrics: netdata's cgroup-network helper prefers setns into each container's netns (which needs SYS_ADMIN and fails without it, once, non-fatally), then FALLS BACK to a host-side veth method that needs no privilege — verified on the live deploy (per-container net charts collect live data with no SYS_ADMIN). Only cosmetic: interfaces labelled by host veth name, not in-container eth0. See criteria. SYS_PTRACE is REQUIRED: apps.plugin runs real-uid = the netdata user (setuid gives euid 0, not real uid 0), so it needs CAP_SYS_PTRACE to read ANY process's /proc/<pid>/io|fd; without it it collects every per-process metric as 0 (not null). The correctness check requires a per-process fds_open to be NON-ZERO, so dropping SYS_PTRACE fails it. (A prior non-null check passed on all-zeros and wrongly derived 5->3, regressing production before this correction.) filesystem: read_only:true, tmpfs:[/tmp]. Derived by drop-test UNDER the capabilities dimension's recommendation (cap_drop:ALL + the 4 caps above, via the csd spec's run.cap_add — the caps mirror of deriving caps under the fs recommendation): the correctness check needs per-process metrics, which need SYS_PTRACE granted. Persistent state (/etc/netdata config, /var/lib/netdata state, /var/cache/netdata dbengine) belongs on named volumes (the official compose shape), supplied in the derivation as service-owned (uid=201) stand-ins — the caps minimum has no CHOWN, so a fresh root-owned dir is a staging artifact, not a read-only finding; a prior deploy's volumes are netdata-owned. /tmp is REQUIRED (the runtime-dir fallback when /run is unwritable — without it the daemon exits "Cannot get/create a run directory"). /var/log/netdata derives REMOVABLE: container logging goes to stdout; the startup chown of the log dir fails non-fatally. READ-ONLY CONSEQUENCE: the entrypoint's PGID-based `groupadd docker` needs a writable /etc/group and fails under read_only — grant docker.sock access via compose `group_add:` instead of the PGID env. See criteria.
Use it
services:
netdata:
image: docker.io/netdata/netdata:v2.10.3
cap_drop: [ALL]
cap_add: [DAC_OVERRIDE, SETGID, SETUID, SYS_PTRACE]
security_opt: ["apparmor=unconfined"]
read_only: true
tmpfs:
- /tmp
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, SETGID, SETUID, SYS_PTRACE
| tool | container-sec-derive 0.7.0 |
|---|---|
| observer | drop-test |
| validated image | docker.io/netdata/netdata@sha256:bcc822ec685d… |
| validated date | 2026-07-04 |
| confidence | moderate |
| validated via | drop-test, ci-smoke |
| workload | profiles/workloads/netdata.sh |
| workload sha256 | ba6ae07c95ee… |
| 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 | host + per-process monitoring (apps.plugin /proc io/fd metrics) | the correctness check requires a per-process fds_open chart to be NON-ZERO (dropping SYS_PTRACE zeroes it) |
| driven | per-container network-interface metrics (cgroup-network) | verified on the live deploy: collects via the host-side veth fallback under the derived set (SYS_ADMIN's setns path fails once, non-fatally) |
| not driven | eBPF collectors (ebpf.plugin) | the image ships no ebpf.plugin, so nothing gates on it (ebpf=yes is a no-op); a custom build with the plugin would need re-derivation |
| not driven | docker.sock-based collectors (container names/state) | socket access is a group grant, not a capability, so it cannot move this dimension; per-process docker metrics come via /proc + SYS_PTRACE |
| not driven | remaining optional collector surface | not exhaustively driven — the reason this dimension's coverage grades partial (ADR-018); no known collector requires a cap outside the derived set |
Drop-test evidence
Each candidate removed in turn, the container restarted, and the workload re-verified.
| Removed | Verdict | Observed |
|---|---|---|
SYS_PTRACE | required | per-process metrics all zero — apps.plugin can't read /proc/<pid>/fd (SYS_PTRACE gap) |
SYS_ADMIN | removable | correct: ready, daemon uid=201, per-process metrics collecting non-zero (app.dockerd_fds_open) |
DAC_OVERRIDE | required | not ready (status=exited): API/apps.plugin not up |
CHOWN | removable | correct: ready, daemon uid=201, per-process metrics collecting non-zero (app.dockerd_fds_open) |
FOWNER | removable | correct: ready, daemon uid=201, per-process metrics collecting non-zero (app.dockerd_fds_open) |
SETUID | required | daemon running as ROOT (privilege drop failed) |
SETGID | required | daemon running as ROOT (privilege drop failed) |
Recorded invocation (run_config)
The minimum is valid for this invocation; a different user:, volume state, or entrypoint can change it.
| network | host |
|---|---|
| pid | host |
| security_opt | apparmor=unconfined |
| mounts | /proc:/host/proc:ro/sys:/host/sys:ro/etc/os-release:/host/etc/os-release:ro/var/log:/host/var/log:ro/var/run/docker.sock:/var/run/docker.sock:ro/etc/passwd:/host/etc/passwd:ro/etc/group:/host/etc/group:ro |
| env | PGID |
Workload script (profiles/workloads/netdata.sh)
#!/usr/bin/env bash
# Correctness check for netdata cap_add bisection (docker.io/netdata/netdata).
#
# netdata is run as a host monitor — pid:host, host /proc,/sys,/var/run/docker.sock
# read-only mounts, host network, and the granted cap set present — then each
# candidate capability is dropped, the container restarted, and this correctness check
# decides "still works CORRECTLY". Correctness is not just liveness: netdata must
# still drop root -> the unprivileged `netdata` user AND still collect per-process
# metrics for non-root processes, or the derivation is wrong.
#
# Required env: NDCONTAINER (target container name or id).
# Exit 0 = correct; non-zero = the dropped capability was required.
set -euo pipefail
: "${NDCONTAINER:?NDCONTAINER must be set}"
API=http://localhost:19999
# chart_nonzero <chart-id> -> 0 if the latest sample has a NON-ZERO value.
# Non-zero, not merely non-null: apps.plugin denied access to a process's /proc
# reports the metric as 0 (not null), so a non-null check passes on broken
# collection. A live process always has open fds > 0.
chart_nonzero() {
curl -fsS --max-time 4 "${API}/api/v1/data?chart=$1&after=-20&points=1&format=json" 2>/dev/null \
| python3 -c 'import sys,json
try:
rows=json.load(sys.stdin).get("data",[])
sys.exit(0 if [v for r in rows for v in r[1:] if v is not None and v != 0] else 1)
except Exception:
sys.exit(1)'
}
# 1. Healthy within the container's own healthcheck window.
deadline=$((SECONDS + 75))
until [ "$(docker inspect -f '{{.State.Health.Status}}' "$NDCONTAINER" 2>/dev/null)" = healthy ]; do
[ "$(docker inspect -f '{{.State.Status}}' "$NDCONTAINER" 2>/dev/null)" != running ] && { echo "not running"; exit 1; }
(( SECONDS >= deadline )) && { echo "did not become healthy"; exit 1; }
sleep 3
done
# 2. HTTP API up and apps.plugin collecting.
curl -fsS --max-time 5 "${API}/api/v1/info" >/dev/null || { echo "API down"; exit 1; }
# capture then grep — piping the (large) charts dump into `grep -q` can SIGPIPE
# curl and, under pipefail, mark the pipeline failed despite a match.
charts="$(curl -fsS --max-time 5 "${API}/api/v1/charts")" || { echo "charts API down"; exit 1; }
grep -q '"apps\.' <<<"$charts" || { echo "apps.plugin not collecting"; exit 1; }
# 3. CORRECTNESS: the netdata daemon dropped privileges (must NOT be uid 0/root).
pid="$(docker inspect -f '{{.State.Pid}}' "$NDCONTAINER")"
uid="$(awk '/^Uid:/{print $2}' /proc/"$pid"/status)"
[ "$uid" != 0 ] || { echo "daemon is running as ROOT (privilege drop failed)"; exit 1; }
# 4. CORRECTNESS: per-process io/fd metrics are actually COLLECTED (non-zero).
# apps.plugin runs real-uid = the netdata user (setuid gives euid 0, not real uid
# 0), so it needs CAP_SYS_PTRACE to read ANY process's /proc/<pid>/io|fd; without
# it, it reports every per-process metric as 0 (not null). So require a per-process
# fds_open to be NON-ZERO — dropping SYS_PTRACE zeros it. (A prior non-null check
# passed on all-zeros and wrongly derived SYS_PTRACE removable, regressing prod.)
ppok=0
for _ in 1 2 3 4 5 6 7 8; do
for ch in app.dockerd_fds_open app.netdata_fds_open app.systemd-journald_fds_open app.containerd_fds_open; do
if chart_nonzero "$ch"; then ppok=1; PPCH="$ch"; break 2; fi
done
sleep 3
done
[ "$ppok" = 1 ] || { echo "per-process metrics all zero — apps.plugin can't read /proc/<pid>/fd (SYS_PTRACE gap)"; exit 1; }
echo "netdata correct: healthy, API up, daemon uid=$uid (non-root), per-process metrics collecting non-zero ($PPCH)"
Dimension: filesystem
read_only: true + tmpfs: /tmp
| tool | container-sec-derive 0.7.0 |
|---|---|
| observer | drop-test |
| validated image | docker.io/netdata/netdata@sha256:bcc822ec685d… |
| validated date | 2026-07-17 |
| confidence | high |
| validated via | drop-test, ci-smoke |
| workload | profiles/workloads/netdata.sh |
| workload sha256 | ba6ae07c95ee… |
| 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 | host + per-process monitoring under read-only rootfs | same correctness predicate as the caps derivation, healthy end-to-end with rootfs read-only |
| not driven | file-based logging ([logs] method = file) | the container default logs to stdout (why /var/log/netdata derives removable); a file-logging config diverges and needs a writable log path |
| not driven | cloud claiming / dyncfg persistent writes | land in /var/lib/netdata, which is a named volume in production (a stand-in here), so they cannot move the tmpfs minimum |
Drop-test evidence
Each candidate removed in turn, the container restarted, and the workload re-verified.
| Removed | Verdict | Observed |
|---|---|---|
/var/log/netdata | removable | correct: ready, daemon uid=201, per-process metrics collecting non-zero (app.dockerd_fds_open) |
/tmp | required | not ready (status=exited): API/apps.plugin not up |
Recorded invocation (run_config)
The minimum is valid for this invocation; a different user:, volume state, or entrypoint can change it.
| network | host |
|---|---|
| pid | host |
| security_opt | apparmor=unconfined |
| mounts | /proc:/host/proc:ro/sys:/host/sys:ro/etc/os-release:/host/etc/os-release:ro/var/log:/host/var/log:ro/var/run/docker.sock:/var/run/docker.sock:ro/etc/passwd:/host/etc/passwd:ro/etc/group:/host/etc/group:ro |
| env | PGID |
Workload script (profiles/workloads/netdata.sh)
#!/usr/bin/env bash
# Correctness check for netdata cap_add bisection (docker.io/netdata/netdata).
#
# netdata is run as a host monitor — pid:host, host /proc,/sys,/var/run/docker.sock
# read-only mounts, host network, and the granted cap set present — then each
# candidate capability is dropped, the container restarted, and this correctness check
# decides "still works CORRECTLY". Correctness is not just liveness: netdata must
# still drop root -> the unprivileged `netdata` user AND still collect per-process
# metrics for non-root processes, or the derivation is wrong.
#
# Required env: NDCONTAINER (target container name or id).
# Exit 0 = correct; non-zero = the dropped capability was required.
set -euo pipefail
: "${NDCONTAINER:?NDCONTAINER must be set}"
API=http://localhost:19999
# chart_nonzero <chart-id> -> 0 if the latest sample has a NON-ZERO value.
# Non-zero, not merely non-null: apps.plugin denied access to a process's /proc
# reports the metric as 0 (not null), so a non-null check passes on broken
# collection. A live process always has open fds > 0.
chart_nonzero() {
curl -fsS --max-time 4 "${API}/api/v1/data?chart=$1&after=-20&points=1&format=json" 2>/dev/null \
| python3 -c 'import sys,json
try:
rows=json.load(sys.stdin).get("data",[])
sys.exit(0 if [v for r in rows for v in r[1:] if v is not None and v != 0] else 1)
except Exception:
sys.exit(1)'
}
# 1. Healthy within the container's own healthcheck window.
deadline=$((SECONDS + 75))
until [ "$(docker inspect -f '{{.State.Health.Status}}' "$NDCONTAINER" 2>/dev/null)" = healthy ]; do
[ "$(docker inspect -f '{{.State.Status}}' "$NDCONTAINER" 2>/dev/null)" != running ] && { echo "not running"; exit 1; }
(( SECONDS >= deadline )) && { echo "did not become healthy"; exit 1; }
sleep 3
done
# 2. HTTP API up and apps.plugin collecting.
curl -fsS --max-time 5 "${API}/api/v1/info" >/dev/null || { echo "API down"; exit 1; }
# capture then grep — piping the (large) charts dump into `grep -q` can SIGPIPE
# curl and, under pipefail, mark the pipeline failed despite a match.
charts="$(curl -fsS --max-time 5 "${API}/api/v1/charts")" || { echo "charts API down"; exit 1; }
grep -q '"apps\.' <<<"$charts" || { echo "apps.plugin not collecting"; exit 1; }
# 3. CORRECTNESS: the netdata daemon dropped privileges (must NOT be uid 0/root).
pid="$(docker inspect -f '{{.State.Pid}}' "$NDCONTAINER")"
uid="$(awk '/^Uid:/{print $2}' /proc/"$pid"/status)"
[ "$uid" != 0 ] || { echo "daemon is running as ROOT (privilege drop failed)"; exit 1; }
# 4. CORRECTNESS: per-process io/fd metrics are actually COLLECTED (non-zero).
# apps.plugin runs real-uid = the netdata user (setuid gives euid 0, not real uid
# 0), so it needs CAP_SYS_PTRACE to read ANY process's /proc/<pid>/io|fd; without
# it, it reports every per-process metric as 0 (not null). So require a per-process
# fds_open to be NON-ZERO — dropping SYS_PTRACE zeros it. (A prior non-null check
# passed on all-zeros and wrongly derived SYS_PTRACE removable, regressing prod.)
ppok=0
for _ in 1 2 3 4 5 6 7 8; do
for ch in app.dockerd_fds_open app.netdata_fds_open app.systemd-journald_fds_open app.containerd_fds_open; do
if chart_nonzero "$ch"; then ppok=1; PPCH="$ch"; break 2; fi
done
sleep 3
done
[ "$ppok" = 1 ] || { echo "per-process metrics all zero — apps.plugin can't read /proc/<pid>/fd (SYS_PTRACE gap)"; exit 1; }
echo "netdata correct: healthy, API up, daemon uid=$uid (non-root), per-process metrics collecting non-zero ($PPCH)"
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
# netdata capability minimum — derived by drop-test (drop-a-cap / restart /
# verify), the authoritative source for cap_add. drop-test covers the full
# container lifetime, so it captures the startup-only caps SETUID/SETGID (the
# root->netdata privilege drop) that runtime observation records as "unused."
# Dropping those leaves netdata healthy while silently running as root.
#
# SYS_ADMIN is removable — the image ships no ebpf.plugin, so ebpf=yes is a no-op.
# It is removable EVEN for per-container network-interface metrics: netdata's
# cgroup-network helper prefers setns into each container's netns (which needs
# SYS_ADMIN and fails without it, once, non-fatally), then FALLS BACK to a
# host-side veth method that needs no privilege — verified on the live deploy
# (per-container net charts collect live data with no SYS_ADMIN). Only cosmetic:
# interfaces labelled by host veth name, not in-container eth0. See criteria.
# SYS_PTRACE is REQUIRED: apps.plugin runs real-uid
# = the netdata user (setuid gives euid 0, not real uid 0), so it needs
# CAP_SYS_PTRACE to read ANY process's /proc/<pid>/io|fd; without it it collects
# every per-process metric as 0 (not null). The correctness check requires a
# per-process fds_open to be NON-ZERO, so dropping SYS_PTRACE fails it. (A prior
# non-null check passed on all-zeros and wrongly derived 5->3, regressing
# production before this correction.)
#
# filesystem: read_only:true, tmpfs:[/tmp]. Derived by drop-test UNDER the
# capabilities dimension's recommendation (cap_drop:ALL + the 4 caps above, via
# the csd spec's run.cap_add — the caps mirror of deriving caps under the fs
# recommendation): the correctness check needs per-process metrics, which need
# SYS_PTRACE granted. Persistent state (/etc/netdata config, /var/lib/netdata
# state, /var/cache/netdata dbengine) belongs on named volumes (the official
# compose shape), supplied in the derivation as service-owned (uid=201) stand-ins
# — the caps minimum has no CHOWN, so a fresh root-owned dir is a staging
# artifact, not a read-only finding; a prior deploy's volumes are netdata-owned.
# /tmp is REQUIRED (the runtime-dir fallback when /run is unwritable — without it
# the daemon exits "Cannot get/create a run directory"). /var/log/netdata derives
# REMOVABLE: container logging goes to stdout; the startup chown of the log dir
# fails non-fatally. READ-ONLY CONSEQUENCE: the entrypoint's PGID-based
# `groupadd docker` needs a writable /etc/group and fails under read_only — grant
# docker.sock access via compose `group_add:` instead of the PGID env. See
# criteria.
schema_version: "1.6"
image: docker.io/netdata/netdata
reference_url: https://tmatens.github.io/container-security-profiles/profiles/docker.io/netdata/netdata.html
applies_to:
tags: ["v2.10.3"]
status: validated
dimensions:
capabilities:
cap_drop: [ALL]
cap_add: [DAC_OVERRIDE, SETGID, SETUID, SYS_PTRACE]
derivation:
tool: container-sec-derive
tool_version: "0.7.0"
sidecar_schema_version: "1.5"
observer: drop-test
validated_image: docker.io/netdata/netdata@sha256:bcc822ec685dd7ee488e9f99b957f479b66301be04f13f346df692ad7de94151
validated_date: "2026-07-04"
duration_seconds: 900
confidence: moderate
workload: profiles/workloads/netdata.sh
workload_sha256: ba6ae07c95eed30155baff45933b0ba9c171694a294539b08119360fc31180f6
validated_via: [drop-test, ci-smoke]
observation_backend:
ig_version: v0.51.0
gadgets: []
run_config:
user: ""
command: []
entrypoint: ""
network: host
pid: host
devices: []
security_opt: [apparmor=unconfined]
mounts:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /etc/os-release:/host/etc/os-release:ro
- /var/log:/host/var/log:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- /etc/passwd:/host/etc/passwd:ro
- /etc/group:/host/etc/group:ro
env: [PGID]
features:
- name: "host + per-process monitoring (apps.plugin /proc io/fd metrics)"
driven: true
why: "the correctness check requires a per-process fds_open chart to be NON-ZERO (dropping SYS_PTRACE zeroes it)"
- name: "per-container network-interface metrics (cgroup-network)"
driven: true
why: "verified on the live deploy: collects via the host-side veth fallback under the derived set (SYS_ADMIN's setns path fails once, non-fatally)"
- name: "eBPF collectors (ebpf.plugin)"
driven: false
why: "the image ships no ebpf.plugin, so nothing gates on it (ebpf=yes is a no-op); a custom build with the plugin would need re-derivation"
- name: "docker.sock-based collectors (container names/state)"
driven: false
why: "socket access is a group grant, not a capability, so it cannot move this dimension; per-process docker metrics come via /proc + SYS_PTRACE"
- name: "remaining optional collector surface"
driven: false
why: "not exhaustively driven — the reason this dimension's coverage grades partial (ADR-018); no known collector requires a cap outside the derived set"
drop_test:
checks:
- removed: SYS_PTRACE
required: true
observed: "per-process metrics all zero — apps.plugin can't read /proc/<pid>/fd (SYS_PTRACE gap)"
- removed: SYS_ADMIN
required: false
observed: "correct: ready, daemon uid=201, per-process metrics collecting non-zero (app.dockerd_fds_open)"
- removed: DAC_OVERRIDE
required: true
observed: "not ready (status=exited): API/apps.plugin not up"
- removed: CHOWN
required: false
observed: "correct: ready, daemon uid=201, per-process metrics collecting non-zero (app.dockerd_fds_open)"
- removed: FOWNER
required: false
observed: "correct: ready, daemon uid=201, per-process metrics collecting non-zero (app.dockerd_fds_open)"
- removed: SETUID
required: true
observed: "daemon running as ROOT (privilege drop failed)"
- removed: SETGID
required: true
observed: "daemon running as ROOT (privilege drop failed)"
filesystem:
read_only: true
tmpfs: [/tmp]
derivation:
tool: container-sec-derive
tool_version: "0.7.0"
sidecar_schema_version: "1.5"
observer: drop-test
validated_image: docker.io/netdata/netdata@sha256:bcc822ec685dd7ee488e9f99b957f479b66301be04f13f346df692ad7de94151
validated_date: "2026-07-17"
duration_seconds: 36
confidence: high
workload: profiles/workloads/netdata.sh
workload_sha256: ba6ae07c95eed30155baff45933b0ba9c171694a294539b08119360fc31180f6
validated_via: [drop-test, ci-smoke]
observation_backend:
ig_version: v0.51.0
gadgets: []
run_config:
user: ""
command: []
entrypoint: ""
network: host
pid: host
devices: []
security_opt: [apparmor=unconfined]
mounts:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /etc/os-release:/host/etc/os-release:ro
- /var/log:/host/var/log:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- /etc/passwd:/host/etc/passwd:ro
- /etc/group:/host/etc/group:ro
env: [PGID]
features:
- name: "host + per-process monitoring under read-only rootfs"
driven: true
why: "same correctness predicate as the caps derivation, healthy end-to-end with rootfs read-only"
- name: "file-based logging ([logs] method = file)"
driven: false
why: "the container default logs to stdout (why /var/log/netdata derives removable); a file-logging config diverges and needs a writable log path"
- name: "cloud claiming / dyncfg persistent writes"
driven: false
why: "land in /var/lib/netdata, which is a named volume in production (a stand-in here), so they cannot move the tmpfs minimum"
drop_test:
checks:
- removed: /var/log/netdata
required: false
observed: "correct: ready, daemon uid=201, per-process metrics collecting non-zero (app.dockerd_fds_open)"
- removed: /tmp
required: true
observed: "not ready (status=exited): API/apps.plugin not up"