catalog / ghcr.io/paperless-ngx/paperless-ngx

ghcr.io/paperless-ngx/paperless-ngx

validated  pin current  tags: 2.18

paperless-ngx — minimum Linux capabilities, derived by drop-test against
the in-stack invocation (against a redis broker). Trims 14 -> 3:
cap_add:[CHOWN, SETGID, SETUID]. The s6-overlay init starts as root,
provisions/chowns the data + media + consume dirs on the fresh volumes
(CHOWN), and drops every worker — granian web, celery worker, celery beat —
to the default PUID 1000 (SETGID/SETUID); dropping any of the three fatals
the s6 rc.init. Notably NO DAC_OVERRIDE (the chown lands ownership before
the writes, so post-chown writes are as the owner) and the web tier is the
unprivileged :8000 (no NET_BIND_SERVICE).

Derived with the in-stack dependent-tier model (deps: the cataloged
redis:8.2.7 broker). A plain-text probe document skips OCR, so trials cover
the full web -> redis -> celery -> media pipeline without the slow OCR path.
See criteria/ghcr.io/paperless-ngx/paperless-ngx.md.
filesystem: read_only:true, tmpfs:[/run:exec, /tmp]. paperless stores its
data/media/consume/export under declared VOLUMEs (persistent). Under a
read-only rootfs its s6-overlay supervisor needs /run writable AND EXECUTABLE
(writes then execs /run/s6/.../init; a default noexec tmpfs fails), plus /tmp
scratch. Derived by drop-test (in-stack redis broker dep).

Use it

services:
  paperless-ngx:
    image: ghcr.io/paperless-ngx/paperless-ngx:2.18
    cap_drop: [ALL]
    cap_add: [CHOWN, SETGID, SETUID]
    security_opt: ["no-new-privileges:true"]
    read_only: true
    tmpfs:
      - /run:exec
      - /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: CHOWN, SETGID, SETUID

toolcontainer-sec-derive 0.7.0
observerdrop-test
validated imageghcr.io/paperless-ngx/paperless-ngx@sha256:3421ebe06ed2…
validated date2026-07-16
confidencehigh
validated viadrop-test, ci-smoke
workloadprofiles/workloads/paperless-ngx.sh
workload sha256ef91d6e14639…
ig versionv0.51.0

Drop-test evidence

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

RemovedVerdictObserved
CHOWNrequiredcontainer exited: /run/s6/basedir/scripts/rc.init: fatal: stopping the container.
DAC_OVERRIDEremovablecorrect: document consumed via the full pipeline, worker uid=1000 (non-root)
FSETIDremovablecorrect: document consumed via the full pipeline, worker uid=1000 (non-root)
FOWNERremovablecorrect: document consumed via the full pipeline, worker uid=1000 (non-root)
MKNODremovablecorrect: document consumed via the full pipeline, worker uid=1000 (non-root)
NET_RAWremovablecorrect: document consumed via the full pipeline, worker uid=1000 (non-root)
SETGIDrequiredcontainer exited: /run/s6/basedir/scripts/rc.init: fatal: stopping the container.
SETUIDrequiredcontainer exited: /run/s6/basedir/scripts/rc.init: fatal: stopping the container.
SETFCAPremovablecorrect: document consumed via the full pipeline, worker uid=1000 (non-root)
SETPCAPremovablecorrect: document consumed via the full pipeline, worker uid=1000 (non-root)
NET_BIND_SERVICEremovablecorrect: document consumed via the full pipeline, worker uid=1000 (non-root)
SYS_CHROOTremovablecorrect: document consumed via the full pipeline, worker uid=1000 (non-root)
KILLremovablecorrect: document consumed via the full pipeline, worker uid=1000 (non-root)
AUDIT_WRITEremovablecorrect: document consumed via the full pipeline, worker 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.

security_optno-new-privileges:true
envPAPERLESS_REDIS
PAPERLESS_SECRET_KEY
PAPERLESS_ADMIN_USER
PAPERLESS_ADMIN_PASSWORD
Workload script (profiles/workloads/paperless-ngx.sh)
#!/usr/bin/env bash
# Workload exerciser for the paperless-ngx reference image (in-stack, against
# a redis broker per PAPERLESS_REDIS). The full document lifecycle: token
# auth -> POST a plain-text document -> poll until consumed (web -> redis
# task queue -> celery worker -> media write). A text doc skips OCR so this
# stays fast. Curl sidecar; capture-then-match. The non-root worker uid
# assert (the s6 PUID drop) lives in the drop-test correctness check.
# Required env: PAPERLESSNGXCONTAINER (target container name or id).
set -euo pipefail
: "${PAPERLESSNGXCONTAINER:?PAPERLESSNGXCONTAINER must be set}"
C="${PAPERLESSNGXCONTAINER}"
PROBE="${CSD_PROBE_CURL_IMAGE:-curlimages/curl@sha256:c1fe1679c34d9784c1b0d1e5f62ac0a79fca01fb6377cdd33e90473c6f9f9a69}"
sc() { docker run --rm --network "container:${C}" "$PROBE" "$@"; }
deadline=$((SECONDS+240))
until case "$(sc -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:8000/api/ 2>/dev/null)" in 200|30?|40?) true;; *) false;; esac; do
    (( SECONDS >= deadline )) && { echo "paperless web never answered" >&2; exit 1; }
    sleep 4
done
tok="$(sc -s --max-time 10 -X POST -H 'Content-Type: application/json' -d '{"username":"csdadmin","password":"CsdProbe-Pw-12345"}' http://localhost:8000/api/token/ 2>/dev/null | grep -oE '"token":"[a-f0-9]+"' | cut -d'"' -f4)"
[ -n "$tok" ] || { echo "token auth failed" >&2; exit 1; }
code="$(sc -s -o /dev/null -w '%{http_code}' --max-time 20 -H "Authorization: Token $tok" -F 'document=@/etc/hostname;filename=csd-probe.txt;type=text/plain' -F 'title=csdprobe' http://localhost:8000/api/documents/post_document/ 2>/dev/null)"
[ "$code" = 200 ] || { echo "document post failed: HTTP ${code:-none}" >&2; exit 1; }
deadline=$((SECONDS+90))
while :; do
    cnt="$(sc -s --max-time 5 -H "Authorization: Token $tok" http://localhost:8000/api/documents/ 2>/dev/null | grep -oE '"count":[0-9]+' | cut -d: -f2)"
    [ "${cnt:-0}" -ge 1 ] && break
    (( SECONDS >= deadline )) && { echo "document never consumed" >&2; exit 1; }
    sleep 4
done

Dimension: filesystem

read_only: true + tmpfs: /run:exec, /tmp

toolcontainer-sec-derive 0.7.0
observerdrop-test
validated imageghcr.io/paperless-ngx/paperless-ngx@sha256:3421ebe06ed2…
validated date2026-07-16
confidencehigh
validated viadrop-test, ci-smoke
workloadprofiles/workloads/paperless-ngx.sh
workload sha256ef91d6e14639…
ig versionv0.51.0

Drop-test evidence

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

RemovedVerdictObserved
/run:execrequiredcontainer failed to start: s6-overlay-suexec: fatal: child failed with exit code 111
/tmprequiredcontainer exited: /run/s6/basedir/scripts/rc.init: fatal: stopping the container.

Recorded invocation (run_config)

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

envPAPERLESS_REDIS
PAPERLESS_SECRET_KEY
PAPERLESS_ADMIN_USER
PAPERLESS_ADMIN_PASSWORD
Workload script (profiles/workloads/paperless-ngx.sh)
#!/usr/bin/env bash
# Workload exerciser for the paperless-ngx reference image (in-stack, against
# a redis broker per PAPERLESS_REDIS). The full document lifecycle: token
# auth -> POST a plain-text document -> poll until consumed (web -> redis
# task queue -> celery worker -> media write). A text doc skips OCR so this
# stays fast. Curl sidecar; capture-then-match. The non-root worker uid
# assert (the s6 PUID drop) lives in the drop-test correctness check.
# Required env: PAPERLESSNGXCONTAINER (target container name or id).
set -euo pipefail
: "${PAPERLESSNGXCONTAINER:?PAPERLESSNGXCONTAINER must be set}"
C="${PAPERLESSNGXCONTAINER}"
PROBE="${CSD_PROBE_CURL_IMAGE:-curlimages/curl@sha256:c1fe1679c34d9784c1b0d1e5f62ac0a79fca01fb6377cdd33e90473c6f9f9a69}"
sc() { docker run --rm --network "container:${C}" "$PROBE" "$@"; }
deadline=$((SECONDS+240))
until case "$(sc -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:8000/api/ 2>/dev/null)" in 200|30?|40?) true;; *) false;; esac; do
    (( SECONDS >= deadline )) && { echo "paperless web never answered" >&2; exit 1; }
    sleep 4
done
tok="$(sc -s --max-time 10 -X POST -H 'Content-Type: application/json' -d '{"username":"csdadmin","password":"CsdProbe-Pw-12345"}' http://localhost:8000/api/token/ 2>/dev/null | grep -oE '"token":"[a-f0-9]+"' | cut -d'"' -f4)"
[ -n "$tok" ] || { echo "token auth failed" >&2; exit 1; }
code="$(sc -s -o /dev/null -w '%{http_code}' --max-time 20 -H "Authorization: Token $tok" -F 'document=@/etc/hostname;filename=csd-probe.txt;type=text/plain' -F 'title=csdprobe' http://localhost:8000/api/documents/post_document/ 2>/dev/null)"
[ "$code" = 200 ] || { echo "document post failed: HTTP ${code:-none}" >&2; exit 1; }
deadline=$((SECONDS+90))
while :; do
    cnt="$(sc -s --max-time 5 -H "Authorization: Token $tok" http://localhost:8000/api/documents/ 2>/dev/null | grep -oE '"count":[0-9]+' | cut -d: -f2)"
    [ "${cnt:-0}" -ge 1 ] && break
    (( SECONDS >= deadline )) && { echo "document never consumed" >&2; exit 1; }
    sleep 4
done

Evidence & provenance

Raw profile YAML
# paperless-ngx — minimum Linux capabilities, derived by drop-test against
# the in-stack invocation (against a redis broker). Trims 14 -> 3:
# cap_add:[CHOWN, SETGID, SETUID]. The s6-overlay init starts as root,
# provisions/chowns the data + media + consume dirs on the fresh volumes
# (CHOWN), and drops every worker — granian web, celery worker, celery beat —
# to the default PUID 1000 (SETGID/SETUID); dropping any of the three fatals
# the s6 rc.init. Notably NO DAC_OVERRIDE (the chown lands ownership before
# the writes, so post-chown writes are as the owner) and the web tier is the
# unprivileged :8000 (no NET_BIND_SERVICE).
#
# Derived with the in-stack dependent-tier model (deps: the cataloged
# redis:8.2.7 broker). A plain-text probe document skips OCR, so trials cover
# the full web -> redis -> celery -> media pipeline without the slow OCR path.
# See criteria/ghcr.io/paperless-ngx/paperless-ngx.md.
# filesystem: read_only:true, tmpfs:[/run:exec, /tmp]. paperless stores its
# data/media/consume/export under declared VOLUMEs (persistent). Under a
# read-only rootfs its s6-overlay supervisor needs /run writable AND EXECUTABLE
# (writes then execs /run/s6/.../init; a default noexec tmpfs fails), plus /tmp
# scratch. Derived by drop-test (in-stack redis broker dep).
#
schema_version: "1.5"
image: ghcr.io/paperless-ngx/paperless-ngx
reference_url: https://tmatens.github.io/container-security-profiles/profiles/ghcr.io/paperless-ngx/paperless-ngx.html
applies_to:
  tags: ["2.18"]
status: validated
dimensions:
  capabilities:
    cap_drop: [ALL]
    cap_add: [CHOWN, SETGID, SETUID]
    derivation:
      tool: container-sec-derive
      tool_version: "0.7.0"
      sidecar_schema_version: "1.5"
      observer: drop-test
      validated_image: ghcr.io/paperless-ngx/paperless-ngx@sha256:3421ebe06ed27662d014046cf5089e612de853aae0c676a2bc72f73b38080e57
      validated_date: "2026-07-16"
      duration_seconds: 1200
      confidence: high
      workload: profiles/workloads/paperless-ngx.sh
      workload_sha256: "ef91d6e1463933ae84bec0dd72136a63046c78d4b185014c412f7479350abd3e"
      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: [PAPERLESS_REDIS, PAPERLESS_SECRET_KEY, PAPERLESS_ADMIN_USER, PAPERLESS_ADMIN_PASSWORD]
      drop_test:
        checks:
          - {removed: CHOWN, required: true, observed: "container exited: /run/s6/basedir/scripts/rc.init: fatal: stopping the container."}
          - {removed: DAC_OVERRIDE, required: false, observed: "correct: document consumed via the full pipeline, worker uid=1000 (non-root)"}
          - {removed: FSETID, required: false, observed: "correct: document consumed via the full pipeline, worker uid=1000 (non-root)"}
          - {removed: FOWNER, required: false, observed: "correct: document consumed via the full pipeline, worker uid=1000 (non-root)"}
          - {removed: MKNOD, required: false, observed: "correct: document consumed via the full pipeline, worker uid=1000 (non-root)"}
          - {removed: NET_RAW, required: false, observed: "correct: document consumed via the full pipeline, worker uid=1000 (non-root)"}
          - {removed: SETGID, required: true, observed: "container exited: /run/s6/basedir/scripts/rc.init: fatal: stopping the container."}
          - {removed: SETUID, required: true, observed: "container exited: /run/s6/basedir/scripts/rc.init: fatal: stopping the container."}
          - {removed: SETFCAP, required: false, observed: "correct: document consumed via the full pipeline, worker uid=1000 (non-root)"}
          - {removed: SETPCAP, required: false, observed: "correct: document consumed via the full pipeline, worker uid=1000 (non-root)"}
          - {removed: NET_BIND_SERVICE, required: false, observed: "correct: document consumed via the full pipeline, worker uid=1000 (non-root)"}
          - {removed: SYS_CHROOT, required: false, observed: "correct: document consumed via the full pipeline, worker uid=1000 (non-root)"}
          - {removed: KILL, required: false, observed: "correct: document consumed via the full pipeline, worker uid=1000 (non-root)"}
          - {removed: AUDIT_WRITE, required: false, observed: "correct: document consumed via the full pipeline, worker uid=1000 (non-root)"}
  filesystem:
    read_only: true
    tmpfs: [/run:exec, /tmp]
    derivation:
      tool: container-sec-derive
      tool_version: "0.7.0"
      sidecar_schema_version: "1.5"
      observer: drop-test
      validated_image: ghcr.io/paperless-ngx/paperless-ngx@sha256:3421ebe06ed27662d014046cf5089e612de853aae0c676a2bc72f73b38080e57
      validated_date: "2026-07-16"
      duration_seconds: 36
      confidence: high
      workload: profiles/workloads/paperless-ngx.sh
      workload_sha256: "ef91d6e1463933ae84bec0dd72136a63046c78d4b185014c412f7479350abd3e"
      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: [PAPERLESS_REDIS, PAPERLESS_SECRET_KEY, PAPERLESS_ADMIN_USER, PAPERLESS_ADMIN_PASSWORD]
      drop_test:
        checks:
          - {removed: /run:exec, required: true, observed: "container failed to start: s6-overlay-suexec: fatal: child failed with exit code 111"}
          - {removed: /tmp, required: true, observed: "container exited: /run/s6/basedir/scripts/rc.init: fatal: stopping the container."}