kadet-hub logokadet-hub

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.

What it deploys

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.

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

The recipe's files are in kadet-hub/recipes. All recipes