Paperless-ngx on Kubernetes without Docker Compose, with Kapitan
Paperless-ngx 3.4.1, a document management system with OCR, rendered by the kadet-hub kubernetes generator 1.14.0 from its Docker Compose file for PostgreSQL. The database is the included recipe postgresql, the task queue valkey. The option ai adds paperless-ai.
- Latest version:
1.0.2, published - Source: Docker Compose file
paperless-ngx/paperless-ngx@b67cfdf3fdfcde4883c4ac136c8545835f679d21(docker/compose/docker-compose.postgres.yml),sha256:85206b8ae6cd74db70998de6479b4c1f7b50c077772a1af2c026c9ecb35b689c - Proof level:
deploys, deployed on kind and smoke tested by the kadet-hub/recipes CI renderspassed in the release run of with check 0.18.0, kind v0.33.0- Output capabilities: none
- Inventory backends:
omegaconf - Prerequisites, installed once per cluster before the recipe:
cnpg - Generators: kubernetes 1.14.0
sha256:787129e1eaed6b9bc868fe4c4aec58d40de6a89fc3cc08424a4cb5822b855080 - Image security: grade F 9078 findings, one per advisory and package, in the 2 images of version 1.0.2: CRITICAL 46, HIGH 1352, MEDIUM 5533, LOW 2115, UNKNOWN 32. Scanned 2026-11-07 11:47 UTC. Full report
The grade rates the upstream images the recipe pins, not the recipe:docker.io/clusterzx/paperless-ai,ghcr.io/paperless-ngx/paperless-ngx.
What it deploys
recipes.paperless-ngx(init.yml)recipes.paperless-ngx.paperless-ngx(paperless-ngx.yml):paperless-ngxrecipes.paperless-ngx.ai(ai.yml), an option:paperless-ngx-ai
Required parameters
applications.paperless-ngx.secret_key signs paperless-ngx’s sessions.
Paperless-ngx refuses to start with the compose file’s change-me.
applications.valkey.password, which the included recipe valkey
requires, is written into PAPERLESS_REDIS as it is, so it must not
contain characters a URL reserves, such as @, /, : or ?. Set both
as refs with the backend of your choice.
With the option ai, also set these. Their values must not contain =
or line breaks. paperless-ai reads its settings file line by line and
drops what follows a second = in a line.
applications.paperless-ngx.ai.user, the paperless-ngx user that paperless-ai acts as, andapplications.paperless-ngx.ai.token, that user’s API token from its profile in paperless-ngx, as a ref;applications.paperless-ngx.ai.url, an OpenAI-compatible endpoint up to/v1, such ashttps://api.openai.com/v1or Ollama’shttp://<host>:11434/v1,applications.paperless-ngx.ai.model, andapplications.paperless-ngx.ai.api_key, as a ref, with any value for an endpoint without keys;applications.paperless-ngx.ai.jwt_secret, as a ref, which signs the logins of paperless-ai’s web interface. Unset, paperless-ai uses a fixed string.
Without one of them the compile stops with
Interpolation key '<key>' not found.
Include
A target includes the recipe and its options as classes and can override anything the recipe sets:
classes:
- recipes.paperless-ngx
- recipes.paperless-ngx.ai # paperless-ai, optional
parameters:
namespace: paperless-ngx
kapitan:
vars:
target: paperless-ngx
applications:
paperless-ngx:
secret_key: <ref>
ai:
user: <paperless-ngx user>
token: <ref>
url: <endpoint>
model: <model>
api_key: <ref>
jwt_secret: <ref>
valkey:
password: <ref>
Fetching it
The quickstart goes through these steps on a kind cluster with the uptime-kuma recipe, from installing Kapitan to the running app.
A separate fetch inventory pins the recipe by version and digest. This is fetch/targets/recipes.yml for version 1.0.2:
parameters:
kapitan:
dependencies:
- type: oci
source: ghcr.io/kadet-hub/recipes/paperless-ngx:1.0.2@sha256:0478a785ac87fc02630484c4dc296587bb9727ffa71c128d809ecff4d88453c7
output_path: inventory/classes/recipes/paperless-ngx
Version 1.0.2 includes postgresql 1.1.1 and valkey 1.0.3. The consumer workflow adds the included recipes at the digests the index lists. Kapitan's --fetch reads no index, so for a local compile pull them by digest first:
tmp=$(mktemp -d)
oras pull -o "$tmp" ghcr.io/kadet-hub/recipes/postgresql@sha256:1e8465058c256cc86995dc8d6f1eb2f095df244fadcaf756935599f4d9ead6a9
mkdir -p inventory/classes/recipes/postgresql
tar -xf "$tmp/postgresql.tar" -C inventory/classes/recipes/postgresql --no-same-owner
oras pull -o "$tmp" ghcr.io/kadet-hub/recipes/valkey@sha256:a50d43cf9080b58040cf84909b0cd7497392709a23f5759e985bd912a9137ce5
mkdir -p inventory/classes/recipes/valkey
tar -xf "$tmp/valkey.tar" -C inventory/classes/recipes/valkey --no-same-owner
The first command fetches the recipe into inventory/classes/recipes/paperless-ngx, the second the generators and compiles:
kapitan compile --inventory-path fetch --fetch --output-path .
kapitan compile --fetch
The first command exits with status 1 because the fetch target compiles nothing. Kapitan's --fetch checks neither the digest nor the attestation. This verifies the attestation of the recipe and of the recipes it includes:
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/paperless-ngx@sha256:0478a785ac87fc02630484c4dc296587bb9727ffa71c128d809ecff4d88453c7 \
--owner kadet-hub \
--cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/postgresql@sha256:1e8465058c256cc86995dc8d6f1eb2f095df244fadcaf756935599f4d9ead6a9 \
--owner kadet-hub \
--cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/valkey@sha256:a50d43cf9080b58040cf84909b0cd7497392709a23f5759e985bd912a9137ce5 \
--owner kadet-hub \
--cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main
The attested index must also list each digest under its recipe with the pinned version, and not as yanked. Verifying by hand shows how to read it.
Settings
| Setting | Inventory |
|---|---|
| paperless-ngx version | components.paperless-ngx.image, tag and digest |
PAPERLESS_URL, required once paperless-ngx is reached under a domain |
components.paperless-ngx.env.PAPERLESS_URL |
PAPERLESS_TIME_ZONE, PAPERLESS_OCR_LANGUAGE, PAPERLESS_OCR_LANGUAGES and the other variables of docker-compose.env |
components.paperless-ngx.env.<variable> |
PAPERLESS_ADMIN_USER and PAPERLESS_ADMIN_PASSWORD, a superuser created at start |
components.paperless-ngx.env.<variable>, the password with secretKeyRef |
size of the claims data, media, export and consume, 1Gi each |
components.paperless-ngx.volume_claims.<claim>.spec.resources.requests.storage |
| resources | components.paperless-ngx.resources |
| the Cluster and its database | applications.postgresql, which postgresql’s README lists |
| paperless-ai version | components.paperless-ngx-ai.image, tag and digest |
size of paperless-ai’s claim data, 1Gi |
components.paperless-ngx-ai.volume_claims.data.spec.resources.requests.storage |
The Service paperless-ngx is a ClusterIP Service on port 8000. The
compose file publishes that port on the host. Here an Ingress or Gateway
in the target exposes it. The compose file creates no account.
Paperless-ngx asks for a superuser on the first visit of its web
interface, or creates one from PAPERLESS_ADMIN_USER.
consume is the folder paperless-ngx watches for new documents, and
export the one its document exporter writes backups to. In compose both
are folders next to the compose file on the host. Here they are claims, so
put files in with kubectl cp or mount storage that other systems reach,
such as a scanner’s network share, in their place. The consumer reads as
UID 1000, and kubectl cp keeps a file’s owner and mode, so a file must be
readable by others, such as with mode 644.
Mapping
The service webserver becomes the component paperless-ngx. db and
broker become the included recipes postgresql and valkey, and
paperless-ngx reaches them through the Services postgresql-cluster-rw and
valkey in place of db and broker.
| Compose | Recipe |
|---|---|
service webserver |
StatefulSet paperless-ngx, Service paperless-ngx on port 8000, Secret paperless-ngx with PAPERLESS_SECRET_KEY |
ghcr.io/paperless-ngx/paperless-ngx:latest |
ghcr.io/paperless-ngx/paperless-ngx:3.4.1@sha256:1a08d8dfc2b51a08de09652dd86ad4d0c611c362382f944533ad38b73125fbc4, which latest was at migration |
service db, postgres:18 with the database, user and password paperless |
the included recipe postgresql, PostgreSQL 18.6 on CloudNativePG. Paperless-ngx reads the host, database, user and the generated password from the Secret postgresql-cluster-app. |
service broker, valkey:9-alpine without a password |
the included recipe valkey, Valkey 9.1.2 with the password of the ACL user default from the Secret valkey-auth |
PAPERLESS_REDIS: redis://broker:6379 |
redis://:$(VALKEY_PASSWORD)@valkey:6379, the password from valkey-auth |
PAPERLESS_DBHOST: db |
from the Secret postgresql-cluster-app, with PAPERLESS_DBNAME, PAPERLESS_DBUSER and PAPERLESS_DBPASS |
env_file: docker-compose.env, which sets PAPERLESS_SECRET_KEY=change-me |
applications.paperless-ngx.secret_key in the Secret paperless-ngx |
volumes data and media |
claim templates data and media, ReadWriteOnce, 1Gi |
bind mounts ./export and ./consume |
claim templates export and consume, ReadWriteOnce, 1Gi |
the image’s HEALTHCHECK, curl on port 8000 every 30s, timeout 10s, 5 retries |
readiness probe with the same values |
port 8000:8000 |
Service port 8000, ClusterIP |
depends_on, restart: unless-stopped |
not rendered. paperless-ngx restarts until the database and Valkey answer. |
.env next to the compose file sets only COMPOSE_PROJECT_NAME, which
names the compose project and does not reach the containers.
Proof
check paperless-ngx --level deploys with tests/target.yml, on kind for
each Kubernetes minor CI tests, with cnpg installed first. The objects
of postgresql and valkey are applied with the recipe. A compose file
has no Kubernetes objects to compare with, so the recipe is proven by
deploying it (RCP-38). tests/target.yml sets a secret key, runs one
PostgreSQL instance and includes the option ai. It adds two components
that only the proof runs: paperless-ngx-llm, busybox’s httpd with a
script that gives every chat completion the same reply, and the Job
paperless-ngx-token, which creates the superuser admin and the API
token that the option’s settings name once paperless-ngx has migrated its
database. The script’s reply names the title tagged by paperless-ai.
tests/smoke copies a PDF into the claim consume and finds the document
by its text through /api/documents/ on the Service’s port 8000 as
admin, which needs the consumer, the task queue in Valkey, the database
and the claim media. It then restarts paperless-ngx-ai, because
paperless-ai may have given up on the API before paperless-ngx answered,
reaches /health through its Service and finds the document under the
title tagged by paperless-ai. That needs paperless-ai to read the
document with the token, call the endpoint and write the title back.
Not tested: a real LLM and what it answers, paperless-ai’s web interface and the export directory.
Versions
1.0.2, :sha256:0478a785ac87fc02630484c4dc296587bb9727ffa71c128d809ecff4d88453c71.0.1, :sha256:e8116ac49d5916f682aceab698fa88d51dc8784d0294891e1b3712aea1eb718b
The recipe's files are in kadet-hub/recipes. All recipes