catalog / docker.io/jellyfin/jellyfin

docker.io/jellyfin/jellyfin

validated  pin current  tags: 10.11

jellyfin — minimum capabilities AND read-only filesystem, derived by
drop-test against the default invocation. ZERO-cap AND fully read-only
(tmpfs:[] — not even /tmp): cap_drop:[ALL] with no cap_add.

The most instructive zero-cap in the catalog: unlike prometheus/memcached
(non-root by construction), jellyfin RUNS AS ROOT with no privilege drop —
and still exercises no capability on the serve path: it binds only the
unprivileged :8096 and writes only to its /config and /cache VOLUMEs
(persistent in real deployments; docker mounts them writable under
read_only). So the full Docker default set is pure over-grant for this
image, and the residual risk is the root uid itself — the stronger
hardening is `user:`, which this profile composes with cleanly.

devices: the /dev/dri grant a hardware-transcode deployment needs, derived
by bpf-observation (trace_open) against `--device /dev/dri` with a real
VAAPI h264 encode via jellyfin's own bundled ffmpeg — the first
observation-derived (non-drop-test) validated dimension in the catalog.
derived_caps is empty: VAAPI needs the device nodes, no extra capability.
Software-only deployments should grant nothing — the caps/fs dimensions
above are already derived without any device. NOT in the re-derivation
manifest: the csd-derive runner VM has no GPU, so this dimension is
revalidated manually on digest bumps (criteria file has the procedure).

Use it

services:
  jellyfin:
    image: docker.io/jellyfin/jellyfin:10.11
    cap_drop: [ALL]
    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 (no cap_add)

toolcontainer-sec-derive 0.7.0
observerdrop-test
validated imagedocker.io/jellyfin/jellyfin@sha256:aefb67e6a7ff…
validated date2026-07-16
confidencehigh
validated viadrop-test, ci-smoke
workloadprofiles/workloads/jellyfin.sh
workload sha2562130b9608949…
ig versionv0.51.0

Drop-test evidence

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

RemovedVerdictObserved
CHOWNremovablecorrect: wizard + admin auth + authorized System/Info ok
DAC_OVERRIDEremovablecorrect: wizard + admin auth + authorized System/Info ok
FSETIDremovablecorrect: wizard + admin auth + authorized System/Info ok
FOWNERremovablecorrect: wizard + admin auth + authorized System/Info ok
MKNODremovablecorrect: wizard + admin auth + authorized System/Info ok
NET_RAWremovablecorrect: wizard + admin auth + authorized System/Info ok
SETGIDremovablecorrect: wizard + admin auth + authorized System/Info ok
SETUIDremovablecorrect: wizard + admin auth + authorized System/Info ok
SETFCAPremovablecorrect: wizard + admin auth + authorized System/Info ok
SETPCAPremovablecorrect: wizard + admin auth + authorized System/Info ok
NET_BIND_SERVICEremovablecorrect: wizard + admin auth + authorized System/Info ok
SYS_CHROOTremovablecorrect: wizard + admin auth + authorized System/Info ok
KILLremovablecorrect: wizard + admin auth + authorized System/Info ok
AUDIT_WRITEremovablecorrect: wizard + admin auth + authorized System/Info ok

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
Workload script (profiles/workloads/jellyfin.sh)
#!/usr/bin/env bash
# Workload exerciser for the jellyfin reference image.
#
# Drives the REAL first-run flow end-to-end: health, the setup wizard REST
# sequence (Startup/Configuration -> Startup/User -> Startup/Complete), token
# auth as the created admin, and an authorized System/Info readback. A fresh
# /config volume means the wizard path executes on first boot.
#
# Probes go through a curl sidecar (the image ships no curl/wget), write
# nothing inside the container, and capture-then-match responses (never
# `curl | grep -q` under pipefail). jellyfin serves as root by default with
# no privilege drop — the hardening lever is `user:`, see the criteria doc.
#
# Required env: JELLYFINCONTAINER (target container name or id).
set -euo pipefail

: "${JELLYFINCONTAINER:?JELLYFINCONTAINER must be set}"
C="${JELLYFINCONTAINER}"
PROBE="${CSD_PROBE_CURL_IMAGE:-curlimages/curl@sha256:c1fe1679c34d9784c1b0d1e5f62ac0a79fca01fb6377cdd33e90473c6f9f9a69}"
sc() { docker run --rm --network "container:${C}" "$PROBE" "$@"; }

deadline=$((SECONDS + 120))
until [ "$(sc -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:8096/health 2>/dev/null)" = 200 ]; do
    (( SECONDS >= deadline )) && { echo "jellyfin never healthy" >&2; exit 1; }
    sleep 3
done

for step in \
    'POST /Startup/Configuration {"UICulture":"en-US","MetadataCountryCode":"US","PreferredMetadataLanguage":"en"}' \
    'GET /Startup/User -' \
    'POST /Startup/User {"Name":"csdadmin","Password":"CsdProbe-Pw-12345"}' \
    'POST /Startup/Complete -'; do
    m="${step%% *}"; rest="${step#* }"; path="${rest%% *}"; body="${rest#* }"
    args=(-s -o /dev/null -w '%{http_code}' --max-time 10 -X "$m")
    [ "$body" != "-" ] && args+=(-H 'Content-Type: application/json' -d "$body")
    code="$(sc "${args[@]}" "http://localhost:8096$path" 2>/dev/null)"
    case "$code" in 2??) ;; *) echo "wizard step $m $path failed: HTTP ${code:-none}" >&2; exit 1;; esac
done

auth="$(sc -s --max-time 10 -X POST -H 'Content-Type: application/json' \
    -H 'Authorization: MediaBrowser Client="csd", Device="csd", DeviceId="csd", Version="1.0"' \
    -d '{"Username":"csdadmin","Pw":"CsdProbe-Pw-12345"}' \
    http://localhost:8096/Users/AuthenticateByName 2>/dev/null)"
tok="$(printf '%s' "$auth" | grep -oE '"AccessToken":"[a-f0-9]+"' | cut -d'"' -f4)"
[ -n "$tok" ] || { echo "admin auth failed" >&2; exit 1; }
info="$(sc -s --max-time 10 -H "Authorization: MediaBrowser Token=\"$tok\"" \
    http://localhost:8096/System/Info 2>/dev/null)"
case "$info" in *'"Version":"'*) ;; *) echo "authorized System/Info failed" >&2; exit 1;; esac

Dimension: filesystem

read_only: true, no tmpfs

toolcontainer-sec-derive 0.7.0
observerdrop-test
validated imagedocker.io/jellyfin/jellyfin@sha256:aefb67e6a7ff…
validated date2026-07-16
confidencehigh
validated viadrop-test, ci-smoke
workloadprofiles/workloads/jellyfin.sh
workload sha2562130b9608949…
ig versionv0.51.0

Drop-test evidence

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

RemovedVerdictObserved
/tmpremovablecorrect: wizard + admin auth + authorized System/Info ok

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
Workload script (profiles/workloads/jellyfin.sh)
#!/usr/bin/env bash
# Workload exerciser for the jellyfin reference image.
#
# Drives the REAL first-run flow end-to-end: health, the setup wizard REST
# sequence (Startup/Configuration -> Startup/User -> Startup/Complete), token
# auth as the created admin, and an authorized System/Info readback. A fresh
# /config volume means the wizard path executes on first boot.
#
# Probes go through a curl sidecar (the image ships no curl/wget), write
# nothing inside the container, and capture-then-match responses (never
# `curl | grep -q` under pipefail). jellyfin serves as root by default with
# no privilege drop — the hardening lever is `user:`, see the criteria doc.
#
# Required env: JELLYFINCONTAINER (target container name or id).
set -euo pipefail

: "${JELLYFINCONTAINER:?JELLYFINCONTAINER must be set}"
C="${JELLYFINCONTAINER}"
PROBE="${CSD_PROBE_CURL_IMAGE:-curlimages/curl@sha256:c1fe1679c34d9784c1b0d1e5f62ac0a79fca01fb6377cdd33e90473c6f9f9a69}"
sc() { docker run --rm --network "container:${C}" "$PROBE" "$@"; }

deadline=$((SECONDS + 120))
until [ "$(sc -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:8096/health 2>/dev/null)" = 200 ]; do
    (( SECONDS >= deadline )) && { echo "jellyfin never healthy" >&2; exit 1; }
    sleep 3
done

for step in \
    'POST /Startup/Configuration {"UICulture":"en-US","MetadataCountryCode":"US","PreferredMetadataLanguage":"en"}' \
    'GET /Startup/User -' \
    'POST /Startup/User {"Name":"csdadmin","Password":"CsdProbe-Pw-12345"}' \
    'POST /Startup/Complete -'; do
    m="${step%% *}"; rest="${step#* }"; path="${rest%% *}"; body="${rest#* }"
    args=(-s -o /dev/null -w '%{http_code}' --max-time 10 -X "$m")
    [ "$body" != "-" ] && args+=(-H 'Content-Type: application/json' -d "$body")
    code="$(sc "${args[@]}" "http://localhost:8096$path" 2>/dev/null)"
    case "$code" in 2??) ;; *) echo "wizard step $m $path failed: HTTP ${code:-none}" >&2; exit 1;; esac
done

auth="$(sc -s --max-time 10 -X POST -H 'Content-Type: application/json' \
    -H 'Authorization: MediaBrowser Client="csd", Device="csd", DeviceId="csd", Version="1.0"' \
    -d '{"Username":"csdadmin","Pw":"CsdProbe-Pw-12345"}' \
    http://localhost:8096/Users/AuthenticateByName 2>/dev/null)"
tok="$(printf '%s' "$auth" | grep -oE '"AccessToken":"[a-f0-9]+"' | cut -d'"' -f4)"
[ -n "$tok" ] || { echo "admin auth failed" >&2; exit 1; }
info="$(sc -s --max-time 10 -H "Authorization: MediaBrowser Token=\"$tok\"" \
    http://localhost:8096/System/Info 2>/dev/null)"
case "$info" in *'"Version":"'*) ;; *) echo "authorized System/Info failed" >&2; exit 1;; esac

Dimension: devices

devices

toolcontainer-sec-derive 0.7.0
observerdevices
validated imagedocker.io/jellyfin/jellyfin@sha256:aefb67e6a7ff…
validated date2026-07-16
confidencemoderate
validated viabpf-observation, ci-smoke
workloadprofiles/workloads/jellyfin-transcode.sh
workload sha256b19af9a9ffae…
ig versionv0.51.0

Recorded invocation (run_config)

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

devices/dev/dri
Workload script (profiles/workloads/jellyfin-transcode.sh)
#!/usr/bin/env bash
# Workload for the jellyfin devices dimension (docker.io/jellyfin/jellyfin):
# exercise the hardware-transcode device-access path.
#
# Drives jellyfin's own bundled ffmpeg (the binary the server spawns for every
# hardware transcode) through a full VAAPI hardware ENCODE on the container's
# granted /dev/dri render node. This is the device-access surface of hw
# transcoding — the same /dev/dri opens a library-triggered playback transcode
# performs — without needing a media library and a playback session; a real
# playback would spawn the identical binary against the identical nodes.
# Correctness: the VAAPI encode must SUCCEED — if the device grant is missing
# or broken, ffmpeg fails to open the hw device and this exits non-zero.
#
# Required env: JFCONTAINER (target container name or id).
set -euo pipefail
: "${JFCONTAINER:?JFCONTAINER must be set}"

# Wait for the container process tree to accept execs (server boot is not
# required for the transcode path, but the container must be running).
deadline=$((SECONDS + 60))
until [ "$(docker inspect -f '{{.State.Status}}' "$JFCONTAINER" 2>/dev/null)" = running ]; do
    (( SECONDS >= deadline )) && { echo "container not running"; exit 1; }
    sleep 2
done

# Full VAAPI pipeline: hwupload -> h264_vaapi encode. testsrc keeps the
# workload self-contained (no media fixture to ship or license).
docker exec "$JFCONTAINER" /usr/lib/jellyfin-ffmpeg/ffmpeg -v error \
    -init_hw_device vaapi=va:/dev/dri/renderD128 \
    -f lavfi -i testsrc=duration=8:size=1920x1080:rate=30 \
    -vf format=nv12,hwupload -c:v h264_vaapi -f null - \
    || { echo "VAAPI hardware encode failed"; exit 1; }

echo "jellyfin transcode correct: VAAPI h264 encode on /dev/dri/renderD128 succeeded"

Evidence & provenance

Raw profile YAML
# jellyfin — minimum capabilities AND read-only filesystem, derived by
# drop-test against the default invocation. ZERO-cap AND fully read-only
# (tmpfs:[] — not even /tmp): cap_drop:[ALL] with no cap_add.
#
# The most instructive zero-cap in the catalog: unlike prometheus/memcached
# (non-root by construction), jellyfin RUNS AS ROOT with no privilege drop —
# and still exercises no capability on the serve path: it binds only the
# unprivileged :8096 and writes only to its /config and /cache VOLUMEs
# (persistent in real deployments; docker mounts them writable under
# read_only). So the full Docker default set is pure over-grant for this
# image, and the residual risk is the root uid itself — the stronger
# hardening is `user:`, which this profile composes with cleanly.
#
# devices: the /dev/dri grant a hardware-transcode deployment needs, derived
# by bpf-observation (trace_open) against `--device /dev/dri` with a real
# VAAPI h264 encode via jellyfin's own bundled ffmpeg — the first
# observation-derived (non-drop-test) validated dimension in the catalog.
# derived_caps is empty: VAAPI needs the device nodes, no extra capability.
# Software-only deployments should grant nothing — the caps/fs dimensions
# above are already derived without any device. NOT in the re-derivation
# manifest: the csd-derive runner VM has no GPU, so this dimension is
# revalidated manually on digest bumps (criteria file has the procedure).
schema_version: "1.5"
image: docker.io/jellyfin/jellyfin
reference_url: https://tmatens.github.io/container-security-profiles/profiles/docker.io/jellyfin/jellyfin.html
applies_to:
  tags: ["10.11"]
status: validated
dimensions:
  capabilities:
    cap_drop: [ALL]
    cap_add: []
    derivation:
      tool: container-sec-derive
      tool_version: "0.7.0"
      sidecar_schema_version: "1.5"
      observer: drop-test
      validated_image: docker.io/jellyfin/jellyfin@sha256:aefb67e6a7ff1debdd154a78a7bbb780fd0c873d8639210a7f6a2016ad2b35db
      validated_date: "2026-07-16"
      duration_seconds: 600
      confidence: high
      workload: profiles/workloads/jellyfin.sh
      workload_sha256: "2130b96089498bbec6ad44b6da76f67db487b6d23a65f2f9c7e1d2275de4533e"
      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"]
        mounts: []
        env: []
      drop_test:
        checks:
          - {removed: CHOWN, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
          - {removed: DAC_OVERRIDE, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
          - {removed: FSETID, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
          - {removed: FOWNER, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
          - {removed: MKNOD, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
          - {removed: NET_RAW, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
          - {removed: SETGID, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
          - {removed: SETUID, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
          - {removed: SETFCAP, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
          - {removed: SETPCAP, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
          - {removed: NET_BIND_SERVICE, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
          - {removed: SYS_CHROOT, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
          - {removed: KILL, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
          - {removed: AUDIT_WRITE, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
  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/jellyfin/jellyfin@sha256:aefb67e6a7ff1debdd154a78a7bbb780fd0c873d8639210a7f6a2016ad2b35db
      validated_date: "2026-07-16"
      duration_seconds: 120
      confidence: high
      workload: profiles/workloads/jellyfin.sh
      workload_sha256: "2130b96089498bbec6ad44b6da76f67db487b6d23a65f2f9c7e1d2275de4533e"
      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"]
        mounts: []
        env: []
      drop_test:
        checks:
          - {removed: /tmp, required: false, observed: "correct: wizard + admin auth + authorized System/Info ok"}
  devices:
    devices: [/dev/dri, /dev/dri/renderD128]
    derived_caps: []
    derivation:
      tool: container-sec-derive
      tool_version: "0.7.0"
      sidecar_schema_version: "1.6"
      observer: devices
      validated_image: docker.io/jellyfin/jellyfin@sha256:aefb67e6a7ff1debdd154a78a7bbb780fd0c873d8639210a7f6a2016ad2b35db
      validated_date: "2026-07-16"
      duration_seconds: 310
      confidence: moderate
      workload: profiles/workloads/jellyfin-transcode.sh
      workload_sha256: "b19af9a9ffae18cf67453c16db82366d5574def6fd19a36e89af3f01fb28dd6e"
      validated_via: [bpf-observation, ci-smoke]
      observation_backend:
        ig_version: v0.51.0
        gadgets: []
      run_config:
        user: ""
        command: []
        entrypoint: ""
        network: ""
        pid: ""
        devices: ["/dev/dri"]
        security_opt: []
        mounts: []
        env: []