catalog / docker.io/library/caddy
docker.io/library/caddy
validated pin current tags: 2
caddy minimum security profile (default file server), derived by drop-test.
Two dimensions:
- capabilities: caddy needs exactly NET_BIND_SERVICE and nothing else. The
requirement is EXEC-TIME and posture-independent, not a runtime port bind:
the /usr/bin/caddy binary carries the file capability
cap_net_bind_service=ep, so under cap_drop:ALL the bounding set no longer
contains it and execve fails EPERM ("operation not permitted") BEFORE any
listen. This is why caddy — unlike httpd/traefik, which bind :80 at runtime
as a running process — needs NO net.ipv4.ip_unprivileged_port_start pin:
setting it to 1024 does not change the verdict (the exec fails regardless).
Every other Docker-default cap was dropped in turn and the file server kept
serving (matches the bisected ground truth in container-sec-derive
testdata/caddy/).
- filesystem: caddy runs correctly under read_only:true with NO tmpfs. Its
only writes are /data (TLS certificates) and /config (config autosave);
dropping either leaves the file server working (they log non-fatal errors).
Those paths hold PERSISTENT data and belong on VOLUMES, not tmpfs — see the
criteria doc. This is the honest contrast to postgres, whose /run/postgresql
socket dir is ephemeral and so is genuinely tmpfs.Use it
services:
caddy:
image: docker.io/library/caddy:2
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
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: NET_BIND_SERVICE
| tool | container-sec-derive 0.6.0 |
|---|---|
| observer | drop-test |
| validated image | docker.io/library/caddy@sha256:844f60b64e47… |
| validated date | 2026-07-17 |
| confidence | high |
| validated via | drop-test, ci-smoke |
| workload | profiles/workloads/caddy.sh |
| workload sha256 | 251b93f73dda… |
| ig version | v0.51.0 |
Drop-test evidence
Each candidate removed in turn, the container restarted, and the workload re-verified.
| Removed | Verdict | Observed |
|---|---|---|
CHOWN | removable | caddy serving the file server on :80 |
DAC_OVERRIDE | removable | caddy serving the file server on :80 |
FSETID | removable | caddy serving the file server on :80 |
FOWNER | removable | caddy serving the file server on :80 |
MKNOD | removable | caddy serving the file server on :80 |
NET_RAW | removable | caddy serving the file server on :80 |
SETGID | removable | caddy serving the file server on :80 |
SETUID | removable | caddy serving the file server on :80 |
SETFCAP | removable | caddy serving the file server on :80 |
SETPCAP | removable | caddy serving the file server on :80 |
NET_BIND_SERVICE | required | exec /usr/bin/caddy: operation not permitted — the setcap'd binary (cap_net_bind_service=ep) fails execve when the cap is outside the bounding set; posture-independent (also fails with ip_unprivileged_port_start=1024) |
SYS_CHROOT | removable | caddy serving the file server on :80 |
KILL | removable | caddy serving the file server on :80 |
AUDIT_WRITE | removable | caddy serving the file server on :80 |
Workload script (profiles/workloads/caddy.sh)
#!/usr/bin/env bash
# Workload exerciser for the caddy reference image (default file server).
#
# Drives caddy's default configuration — serve a file-server GET on :80, survive
# a SIGUSR1 config reload, serve again — all via `docker exec` so no host ports
# need publishing. Returns 0 if every step succeeded.
#
# Required env: CADDY_CONTAINER (target container name or id).
set -euo pipefail
: "${CADDY_CONTAINER:?CADDY_CONTAINER must be set}"
serve() { docker exec "${CADDY_CONTAINER}" wget -qO- http://localhost/ >/dev/null; }
# Wait until the file server responds.
deadline=$((SECONDS + 30))
until serve 2>/dev/null; do
if (( SECONDS >= deadline )); then
echo "caddy did not become ready in 30s" >&2
exit 1
fi
sleep 1
done
# GET — file server happy path.
serve
# SIGUSR1 — reload the configuration from the Caddyfile.
docker kill --signal=SIGUSR1 "${CADDY_CONTAINER}" >/dev/null
sleep 1
# Still serving after the reload.
serve
Dimension: filesystem
read_only: true, no tmpfs
| tool | container-sec-derive 0.6.0 |
|---|---|
| observer | drop-test |
| validated image | docker.io/library/caddy@sha256:844f60b64e47… |
| validated date | 2026-07-17 |
| confidence | high |
| validated via | drop-test, ci-smoke |
| workload | profiles/workloads/caddy.sh |
| workload sha256 | 251b93f73dda… |
| ig version | v0.51.0 |
Drop-test evidence
Each candidate removed in turn, the container restarted, and the workload re-verified.
| Removed | Verdict | Observed |
|---|---|---|
/data | removable | caddy serving the file server on :80 |
/config | removable | caddy serving the file server on :80 |
Workload script (profiles/workloads/caddy.sh)
#!/usr/bin/env bash
# Workload exerciser for the caddy reference image (default file server).
#
# Drives caddy's default configuration — serve a file-server GET on :80, survive
# a SIGUSR1 config reload, serve again — all via `docker exec` so no host ports
# need publishing. Returns 0 if every step succeeded.
#
# Required env: CADDY_CONTAINER (target container name or id).
set -euo pipefail
: "${CADDY_CONTAINER:?CADDY_CONTAINER must be set}"
serve() { docker exec "${CADDY_CONTAINER}" wget -qO- http://localhost/ >/dev/null; }
# Wait until the file server responds.
deadline=$((SECONDS + 30))
until serve 2>/dev/null; do
if (( SECONDS >= deadline )); then
echo "caddy did not become ready in 30s" >&2
exit 1
fi
sleep 1
done
# GET — file server happy path.
serve
# SIGUSR1 — reload the configuration from the Caddyfile.
docker kill --signal=SIGUSR1 "${CADDY_CONTAINER}" >/dev/null
sleep 1
# Still serving after the reload.
serve
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
# caddy minimum security profile (default file server), derived by drop-test.
# Two dimensions:
# - capabilities: caddy needs exactly NET_BIND_SERVICE and nothing else. The
# requirement is EXEC-TIME and posture-independent, not a runtime port bind:
# the /usr/bin/caddy binary carries the file capability
# cap_net_bind_service=ep, so under cap_drop:ALL the bounding set no longer
# contains it and execve fails EPERM ("operation not permitted") BEFORE any
# listen. This is why caddy — unlike httpd/traefik, which bind :80 at runtime
# as a running process — needs NO net.ipv4.ip_unprivileged_port_start pin:
# setting it to 1024 does not change the verdict (the exec fails regardless).
# Every other Docker-default cap was dropped in turn and the file server kept
# serving (matches the bisected ground truth in container-sec-derive
# testdata/caddy/).
# - filesystem: caddy runs correctly under read_only:true with NO tmpfs. Its
# only writes are /data (TLS certificates) and /config (config autosave);
# dropping either leaves the file server working (they log non-fatal errors).
# Those paths hold PERSISTENT data and belong on VOLUMES, not tmpfs — see the
# criteria doc. This is the honest contrast to postgres, whose /run/postgresql
# socket dir is ephemeral and so is genuinely tmpfs.
schema_version: "1.5"
image: docker.io/library/caddy
reference_url: https://tmatens.github.io/container-security-profiles/profiles/docker.io/library/caddy.html
applies_to:
tags: ["2"]
status: validated
dimensions:
capabilities:
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
derivation:
tool: container-sec-derive
tool_version: "0.6.0"
sidecar_schema_version: "1.5"
observer: drop-test
validated_image: docker.io/library/caddy@sha256:844f60b64e4724a5aa8245e019dace0d3f199f7433ce6c57676cb30a920dbad9
validated_date: "2026-07-17"
duration_seconds: 30
confidence: high
workload: profiles/workloads/caddy.sh
workload_sha256: "251b93f73dda3d5ca12f75edbda4aa8d80d50aea853b2699445dfbf030196726"
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: CHOWN, required: false, observed: "caddy serving the file server on :80"}
- {removed: DAC_OVERRIDE, required: false, observed: "caddy serving the file server on :80"}
- {removed: FSETID, required: false, observed: "caddy serving the file server on :80"}
- {removed: FOWNER, required: false, observed: "caddy serving the file server on :80"}
- {removed: MKNOD, required: false, observed: "caddy serving the file server on :80"}
- {removed: NET_RAW, required: false, observed: "caddy serving the file server on :80"}
- {removed: SETGID, required: false, observed: "caddy serving the file server on :80"}
- {removed: SETUID, required: false, observed: "caddy serving the file server on :80"}
- {removed: SETFCAP, required: false, observed: "caddy serving the file server on :80"}
- {removed: SETPCAP, required: false, observed: "caddy serving the file server on :80"}
- {removed: NET_BIND_SERVICE, required: true, observed: "exec /usr/bin/caddy: operation not permitted — the setcap'd binary (cap_net_bind_service=ep) fails execve when the cap is outside the bounding set; posture-independent (also fails with ip_unprivileged_port_start=1024)"}
- {removed: SYS_CHROOT, required: false, observed: "caddy serving the file server on :80"}
- {removed: KILL, required: false, observed: "caddy serving the file server on :80"}
- {removed: AUDIT_WRITE, required: false, observed: "caddy serving the file server on :80"}
filesystem:
read_only: true
tmpfs: []
derivation:
tool: container-sec-derive
tool_version: "0.6.0"
sidecar_schema_version: "1.5"
observer: drop-test
validated_image: docker.io/library/caddy@sha256:844f60b64e4724a5aa8245e019dace0d3f199f7433ce6c57676cb30a920dbad9
validated_date: "2026-07-17"
duration_seconds: 30
confidence: high
workload: profiles/workloads/caddy.sh
workload_sha256: "251b93f73dda3d5ca12f75edbda4aa8d80d50aea853b2699445dfbf030196726"
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: /data, required: false, observed: "caddy serving the file server on :80"}
- {removed: /config, required: false, observed: "caddy serving the file server on :80"}