Evidence-backed minimum-security profiles for container images — the capabilities and read-only-filesystem config each image actually needs, derived by removing privileges one at a time and proving the container breaks without them. Not guidance copied from a blog post: every profile is digest-pinned, backed by a committed workload script, and carries its full derivation evidence in the file.
⏸️ Catalog updates paused (2026-08-02). The deriving tool (container-sec-derive) is on a maintenance freeze, so automated re-derivation is paused. Existing profiles remain valid for their pinned digests — that is exactly what digest-pinning is for — but they will not track newer image digests while paused. The weekly staleness check still runs and flags digest drift in a tracking issue; acting on those flags is deferred until the pause lifts. Profile requests and mismatch reports are still welcome (issues are watched), but expect new derivations and exploratory→validated promotions to wait for the pause to end.
For example, immich ships its postgres with cap_add including FOWNER — the
derived, drop-tested minimum is four capabilities, and FOWNER is a genuine
over-grant:
services:
postgres:
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
cap_drop: [ALL]
cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]
security_opt: ["no-new-privileges:true"]| Image | Tags | Minimum (all dimensions apply as a unit) | Confidence |
|---|---|---|---|
codeberg.org/forgejo/forgejo |
15 |
capabilities: cap_add: [CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETGID, SETUID, SYS_CHROOT] (NET_BIND_SERVICE under ip_unprivileged_port_start=1024) |
high |
docker.io/grafana/alloy |
v1.16.2 |
capabilities: cap_drop: [ALL] (zero-cap) · filesystem: read_only: true |
moderate (caps — collectors) |
docker.io/grafana/grafana |
13.0.2 |
capabilities: cap_drop: [ALL] (zero-cap) · filesystem: read_only: true |
high (caps) · moderate (fs) |
docker.io/grafana/loki |
3.7.2 |
capabilities: cap_drop: [ALL] (zero-cap) · filesystem: read_only: true |
high |
docker.io/jellyfin/jellyfin |
10.11 |
capabilities: cap_drop: [ALL] (zero-cap) · filesystem: read_only: true · devices: [/dev/dri] (hw transcode) |
high (caps/fs) · moderate (devices) |
docker.io/binwiederhier/ntfy |
v2.14.0 |
capabilities: cap_add: [NET_BIND_SERVICE] (under ip_unprivileged_port_start=1024) · filesystem: read_only: true |
high |
docker.io/library/caddy |
2 |
capabilities: cap_add: [NET_BIND_SERVICE] · filesystem: read_only: true |
high |
docker.io/library/haproxy |
3.2 |
capabilities: cap_drop: [ALL] (zero-cap, non-root) · filesystem: read_only: true |
high |
docker.io/library/httpd |
2.4 |
capabilities: cap_add: [NET_BIND_SERVICE, SETGID, SETUID] (under ip_unprivileged_port_start=1024) · filesystem: read_only: true, tmpfs: [/usr/local/apache2/logs] |
high |
docker.io/library/eclipse-mosquitto |
2.0 |
capabilities: cap_add: [SETGID, SETUID] · filesystem: read_only: true |
high |
docker.io/library/mariadb |
11.4 |
capabilities: cap_add: [SETGID, SETUID] · filesystem: read_only: true, tmpfs: [/run/mysqld, /tmp] |
high |
docker.io/adguard/adguardhome |
v0.107.78 |
capabilities: cap_add: [DAC_OVERRIDE, NET_BIND_SERVICE] (DNS-only, under ip_unprivileged_port_start=1024) · filesystem: read_only: true |
moderate (caps — DHCP=NET_ADMIN) |
docker.io/library/memcached |
1.6 |
capabilities: cap_drop: [ALL] (zero-cap) · filesystem: read_only: true |
high |
docker.io/louislam/uptime-kuma |
2 |
capabilities: cap_add: [DAC_OVERRIDE, FOWNER] (ping monitors add NET_RAW) · filesystem: read_only: true, tmpfs: [/tmp] |
moderate (caps — ping=NET_RAW) |
docker.io/minio/minio |
RELEASE.2025-09-07… |
capabilities: cap_drop: [ALL] (zero-cap) · filesystem: read_only: true |
high |
docker.io/library/mongo |
8.0 |
capabilities: cap_add: [SETGID, SETUID] · filesystem: read_only: true, tmpfs: [/tmp] |
high |
quay.io/keycloak/keycloak |
26.4 |
capabilities: cap_drop: [ALL] (zero-cap, non-root) |
high |
docker.io/library/mysql |
8.4 |
capabilities: cap_add: [DAC_OVERRIDE, SETGID, SETUID] · filesystem: read_only: true, tmpfs: [/tmp, /var/run/mysqld] |
high |
docker.io/library/nextcloud |
32 |
capabilities: cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, NET_BIND_SERVICE, SETGID, SETUID] (under ip_unprivileged_port_start=1024) · filesystem: read_only: true, tmpfs: [/var/run/apache2] |
high |
docker.io/library/postgres |
16 |
capabilities: cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID] · filesystem: read_only: true, tmpfs: [/run/postgresql] |
high |
docker.io/library/traefik |
v3.7 |
capabilities: cap_add: [NET_BIND_SERVICE] (under ip_unprivileged_port_start=1024) · filesystem: read_only: true |
high |
docker.io/library/wordpress |
6.9 |
capabilities: cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, NET_BIND_SERVICE, SETGID, SETUID] (under ip_unprivileged_port_start=1024) · filesystem: read_only: true, tmpfs: [/var/run/apache2] |
high |
docker.io/library/rabbitmq |
4.3 |
capabilities: cap_add: [SETGID, SETUID] |
high |
docker.io/syncthing/syncthing |
2.0 |
capabilities: cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID] · filesystem: read_only: true |
high |
docker.io/library/redis |
8.2.7 |
capabilities: cap_add: [SETGID, SETUID] · filesystem: read_only: true |
high |
docker.io/prom/prometheus |
v3.13.1 |
capabilities: cap_drop: [ALL] (zero-cap) · filesystem: read_only: true |
high |
docker.io/pihole/pihole |
2026.07.2 |
capabilities: cap_add: [CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETFCAP, SETGID, SETUID] (DNS-only; NO no-new-privileges — see criteria) · filesystem: read_only: false (infeasible — see criteria) |
moderate (caps) · high (fs) |
docker.io/netdata/netdata |
v2.10.3 |
capabilities: cap_add: [DAC_OVERRIDE, SETGID, SETUID, SYS_PTRACE] · filesystem: read_only: true, tmpfs: [/tmp] (persistent dirs on named volumes; group_add: for docker.sock — see criteria) |
moderate (caps — see coverage) · high (fs) |
docker.io/valkey/valkey |
9 |
capabilities: cap_add: [SETGID, SETUID] · filesystem: read_only: true |
high · app-tier ✓ |
ghcr.io/gethomepage/homepage |
v1.13.2 |
filesystem: read_only: true, tmpfs: [/app/.next/cache] |
high |
ghcr.io/home-assistant/home-assistant |
2026.7.1 |
capabilities: cap_add: [DAC_OVERRIDE] · filesystem: read_only: true, tmpfs: [/run:exec] |
moderate (caps — base install) |
ghcr.io/paperless-ngx/paperless-ngx |
2.18 |
capabilities: cap_add: [CHOWN, SETGID, SETUID] · filesystem: read_only: true, tmpfs: [/run:exec, /tmp] |
high |
ghcr.io/immich-app/postgres |
14-vectorchord… |
capabilities: cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID] · filesystem: read_only: true, tmpfs: [/etc/postgresql, /var/run/postgresql] |
high · app-tier ✓ |
All capability profiles are cap_drop: [ALL] plus the listed cap_add.
Confidence is high only where a single workload provably bounds the
image's privilege surface; a dimension is moderate when an unexercised
optional feature could need more privilege than the derived minimum (e.g.
Pi-hole's DHCP → NET_ADMIN, uptime-kuma's ping monitors → NET_RAW, netdata's
container-network collector → SYS_ADMIN) — the criteria doc names what's
uncovered. The model is compose-lint ADR-018.
A dimension missing from a row (most commonly filesystem) has not yet been
derived for that image — absence means not tested, not that the image can't
run read-only. Each profile's header comment calls this out explicitly.
The browsable catalog site
renders every profile with its copy-paste snippet, drop-test evidence table,
recorded invocation, and criteria — or read the YAML directly under
catalog/.
Want an image added? Request a profile.
Directly: copy the dimension into your compose file (each profile page on
the site has a ready snippet). One caveat that matters: a profile is the
minimum for the recorded invocation — the run_config block in the
profile. A different user:, a pre-initialised vs fresh data volume, or an
entrypoint override changes the minimum. Read the profile's criteria/ doc
before adopting; if a profile breaks your deployment,
report the mismatch.
Via compose-lint (opt-in, experimental preview): point compose-lint ≥ 0.13 at a clone of this catalog and its findings gain image-specific guidance:
# .compose-lint.yml
profiles:
enabled: true
path: /path/to/container-security-profiles/catalogCL-0006 Service does not drop all capabilities. …
fix: …
profile hint (csd-derived, confidence high, from docker.io/library/postgres@sha256:fe03a76…,
tag match — compose-lint can't see your runtime, confirm it fits your setup):
observed minimum is cap_drop: [ALL] + cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]
Enrichment is advisory only — it never creates, drops, or reclassifies a
finding, and only validated profiles are consumed.
Every validated profile clears a machine-checked bar (CI runs it on every
change; make validate runs the same locally):
- Schema-valid against the versioned compose-lint profile schema, fetched
from a pinned compose-lint commit (
contract/compose-lint.ref) at validation time — no vendored copy to drift. - Digest-pinned:
validated_imagerecords the exact…@sha256:…the evidence was produced against, andapplies_to.tagsmay pin only immutable version tags — a profile derived againstlatestis meaningless. - Workload-backed: the exerciser script is committed under
profiles/workloads/and hash-verified (workload_sha256). Workloads exercise the image's real function and assert privilege drops (a health check alone is not enough). - Evidence in-file, per derivation method:
- drop-test — every granted element removed in turn, the container
restarted, the workload re-verified; the per-element results are the
drop_test.checkstable in the profile. This catches startup-only minimums (a data-dirchown, a root→user privilege drop) that runtime observation is structurally blind to. The whole current catalog is derived this way. - bpf-observation — live eBPF observation over a running workload, ≥ 300 s.
- drop-test — every granted element removed in turn, the container
restarted, the workload re-verified; the per-element results are the
- App-tier verified (where marked): the hardening was additionally proven at the service level — the full stack brought up with the minimum applied and driven through its real API, including an over-hardening probe showing the check catches a too-tight config.
- Freshness-tracked: a weekly
stalenessworkflow compares each pinned digest to the tag's currently published digest and opens a tracking issue on drift, flagging the profile for re-derivation.
Trust model: endorsed (validated) profiles are ones maintainer
automation derives and can re-derive. External contributions land as
exploratory (advisory only, never used for enrichment) until reproduced —
see CONTRIBUTING.md.
Catalog and criteria paths are registry-namespaced
(<registry>/<org>/<image>), mirroring the fully-qualified image reference.
catalog/….yaml— validated profiles;catalog/exploratory/…— drafts below the promotion bar.criteria/….md— per-image scenarios and pass criteria.profiles/workloads/*.sh— the committed exerciser scripts.derivation/manifest.yaml— the re-derivation spec: how to re-derive each profile representatively (image, dimension, method, workload).contract/compose-lint.ref+scripts/fetch-contract.sh— the pinned schema/validator contract (fetched into.contract/, gitignored).scripts/check_staleness.py— the registry-only digest-drift check.scripts/build_site.py— the static catalog-site generator (make site; deployed by.github/workflows/pages.yml).
pip install -r requirements.txt
make validate # fetches the pinned contract, then validates the catalogCI runs the same on every PR and push to main, plus a smoke build of the catalog site.
This is the external profile catalog described in compose-lint ADR-017: the two are tied by exactly one thing — the versioned profile schema. compose-lint ships the schema, loader, and validator but no profiles; this catalog ships profiles but no code dependency. Data flows one way (catalog → consumer), and consumption is opt-in. The name is consumer-neutral on purpose: compose-lint is the first consumer, not the only possible one.
Profiles are derived by container-sec-derive (csd) — a runtime tool that
observes a running container via eBPF (capabilities, filesystem writes,
devices, egress) and drop-tests candidate minimums against a real workload.
csd is not yet published; until it is, every profile carries enough evidence
(run_config, workload script, per-element drop-test results) to reproduce
the derivation with any harness: apply the profile, run the workload, remove
one element, watch it break.
For a narrative walk-through of what this looks like in practice — images that
loudly document a need for --privileged or root, and what they actually need
when you measure — see
docs/articles/does-your-container-need-root.md.
It includes a cautionary case (netdata) where measurement first over-derived,
then over-corrected, before a live deployment settled it.
Profile requests, mismatch reports, and profile contributions are all welcome — see CONTRIBUTING.md. Security policy: SECURITY.md.
MIT. Profiles are data — reuse them freely with attribution.