Table of Contents
- paperless-ngx
- Prerequisites
- Installation
- Configurable values
- Global
- secrets
- ingress
- httpRoute
- paperless
- smbConnection
- oidc
- valkeySubchart and valkey (subchart)
- networkPolicy
- Operating notes
- Routing: Ingress or Gateway API
- Redis fails quietly, Postgres does not
- The bundled Valkey: one step of it is in the vault
- Never change valkey/ and paperless-ngx/ in the same pull request
- What a green pod does not prove
- The root init container that hands over /run
- How documents get in
- The consume CronJob is the one probes exception
- Any cron notation works now
- The mover runs where the server runs
- Moving the log off the data volume
- The SMB PersistentVolume outlives the release
- One replica, one volume
- Rotating a secret does not restart the pod
- A childless block means "defaults"
- The Tika endpoints carry no scheme
- OCR languages come with the image
- paperless.scripts does nothing
- secrets.oidcSecretRef is gone
- No client secret, and no value that pretends otherwise
- Locking yourself out with OIDC
- Port-forwarding and CSRF
- Why that rule names 9998 and 3000, not the Services' 80
- Mail fetching is not permitted by default
- Two pods of this release are not covered by this object
- The egress ports are the standard ones, not your configured ones
- The probed port is open inside the cluster by default
- The policy is only a boundary where the CNI enforces one
- Upgrades and backup
- CI
- See also
paperless-ngx
Deploys Paperless-ngx, a document management system, as a single-replica StatefulSet with two PVCs. It is the largest chart in this repository: a root init container that hands over /run, an optional SMB consume CronJob with its own PersistentVolume, and a scripts ConfigMap that is currently switched off. Postgres is not part of this chart. Redis does not have to be either: since Gitea #78 the chart can bring this repo's own valkey chart along as an optional subchart, off by default — see the bundled Valkey.
| Image | ghcr.io/paperless-ngx/paperless-ngx |
| Versions | not listed on purpose — read the current pair off paperless-ngx/Chart.yaml, see Chart index |
| Workloads | StatefulSet (1 replica) with a root init container; optional consume CronJob; plus the subchart's own StatefulSet when it is on |
| Storage | main PVC (RWO, paperless.files.storage) + consume PVC (RWX, hardcoded 1Gi); both on the hardcoded storage class longhorn. The subchart adds its own PVC on its own class |
| Routing | two mutually exclusive modes, Ingress (default) or Gateway API (httpRoute.enabled). ingress.enabled exists since group 2 of the Gateway-API sweep and defaults to true; before that the Ingress was always rendered — see Routing: Ingress or Gateway API |
| External dependencies | Postgres; Redis unless the subchart provides it; optionally Tika + Gotenberg, an OIDC provider, the smb.csi.k8s.io driver |
| Subchart | optional valkey — this repo's own chart, from the published index, off by default and gated on valkeySubchart.enabled. Configured as a queue, the opposite of stalwart's cache |
| Secrets | 3 × OnePasswordItem, a 4th with SMB enabled, a 5th rendered by the subchart (and it may name the same item as the Redis one) |
| Install-test | ci/paperless-ngx/ (fixtures + test values, INSTALL_TIMEOUT=600) — runs with the subchart on |
Prerequisites
- 1Password operator in the cluster, plus the items described in the fields below.
- Postgres reachable before the first start, and Redis too unless you let the chart bring it: either point
redis-connection-urlat an existing instance or setvalkeySubchart.enabled: true, which deploys this repo's own valkey chart alongside. They fail in opposite ways and only one of them is loud — see Redis fails quietly, Postgres does not. - With
valkeySubchart.enabled: a third fieldauth-confin the Redis 1Password item, and the connection url repointed at the subchart's Service. Both are steps, not hints — see the bundled Valkey. - A storage class named
longhornthat can serve ReadWriteMany for the consume claim. Both PVCs hardcode it; there is no value to change it. - cert-manager with the
ClusterIssuernamed iningress.clusterIssuer, and DNS foringress.domainpointing at the ingress controller. In Gateway mode neither is needed from this release: the certificate sits on the Gateway's listener, and what is needed instead is the Gateway-API CRDs plus a Gateway whose listener admits routes from this namespace. - With
smbConnection.enabled: thesmb.csi.k8s.ioCSI driver, which this chart does not install. - With
oidc.enabled: an OIDC client that completes the flow without a client secret (PKCE).
Installation
The registry URL and its credentials live in 1Password (item jlab-registry-access: username, password, charts-url) and never in a repository or a shell history file.
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
helm repo update
helm install paperless jlab/paperless-ngx \
--namespace paperless --create-namespace \
-f my-values.yaml
Minimal my-values.yaml:
secrets:
databaseSecretRef: op://vault/paperless-db
redisSecretRef: op://vault/paperless-redis
paperlessSecretRef: op://vault/paperless
ingress:
domain: paperless.example.com
clusterIssuer: letsencrypt
The same installation in Gateway mode instead — the Gateway, its class and its certificate belong to the cluster, not to this release. Note that ingress.enabled defaults to true here, so it has to be switched off explicitly or the render aborts:
ingress:
enabled: false
domain: paperless.example.com # still required: it is PAPERLESS_URL
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
This needs the Gateway-API CRDs installed and a Gateway whose listener admits routes from this namespace; ingress.clusterIssuer is not used. ingress.domain is still required — it is also PAPERLESS_URL, the only origin the release declares to Django. And paperless.files.maxUploadFileSize stops being enforced for web uploads; the consume directory is unaffected. See Routing: Ingress or Gateway API.
helm install prints the chart's NOTES.txt, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
What the 1Password items must contain
The chart renders OnePasswordItem CRs; the operator turns each into a Kubernetes Secret whose keys are the field names of the 1Password item. A missing field does not fail the install: the release goes through and the pod then hangs in CreateContainerConfigError naming the key it could not find.
| Values key | Kubernetes Secret | Fields |
|---|---|---|
secrets.databaseSecretRef |
<release>-paperless-ngx-db-secret |
server (the host — the field is called server, not host), port, database, username, password. The engine is pinned to postgresql; no value changes it. |
secrets.redisSecretRef |
<release>-paperless-ngx-redis-secret |
redis-connection-url (e.g. redis://:PASSWORD@redis:6379), key-prefix (PAPERLESS_REDIS_PREFIX — set it per release, or two releases sharing one Redis consume each other's task queue), and with the subchart on a third field auth-conf holding the complete line requirepass PASSWORD — not a bare password |
valkey.secrets.authSecretRef |
<release>-valkey-secret |
Rendered by the subchart, only when it is on. Reads the field auth-conf. Normally the same item as secrets.redisSecretRef above, which is why that item carries three fields; a different item is allowed and then needs the auth-conf field of its own |
secrets.paperlessSecretRef |
<release>-paperless-ngx-secret |
username (used twice, as PAPERLESS_ADMIN_USER and PAPERLESS_ADMIN_MAIL, so it has to work as a login name and as an address), password, paperless-secret-key (Django's SECRET_KEY) |
secrets.smbConnectionSecretRef |
<release>-paperless-ngx-smb-secret |
Read by no pod: it is wired into the PersistentVolume as nodeStageSecretRef and the smb.csi.k8s.io driver reads it itself — usually username and password, plus domain where the server wants one. The chart names no key, so a wrong field name surfaces as a mount failure on the CronJob's pod. |
PAPERLESS_ADMIN_* is only acted on while that superuser does not exist yet. Editing the password field after the first start changes nothing — reset it in the pod:
kubectl -n <namespace> exec -it <release>-paperless-ngx-sfs-0 -- \
python3 /usr/src/paperless/src/manage.py changepassword <user>
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders every workload with replicas: 0 and suspends the consume CronJob. Takes precedence over paperless.replicas. PVCs, Secrets, Service and Ingress stay. |
global |
object | {} |
Helm's cross-chart convention. Read by nothing here; kept open so an umbrella install cannot break on it. |
secrets
| Key | Type | Default | Description |
|---|---|---|---|
secrets.databaseSecretRef |
string | null |
Required. op:// path of the Postgres item. |
secrets.redisSecretRef |
string | null |
Required. op:// path of the Redis item. |
secrets.paperlessSecretRef |
string | null |
Required. op:// path of the app item. |
secrets.smbConnectionSecretRef |
string | null |
Required while smbConnection.enabled is true, ignored otherwise. |
secrets.oidcSecretRef was removed in 13.0.0 and is no longer accepted — see secrets.oidcSecretRef is gone.
ingress
| Key | Type | Default | Description |
|---|---|---|---|
ingress.enabled |
bool | true |
Gates the whole Ingress template. New with group 2 of the Gateway-API sweep — before that this chart was the last one in the repository rendering its Ingress unconditionally. Mutually exclusive with httpRoute.enabled, and since it defaults to true it must be switched off explicitly to use the Gateway mode. |
ingress.domain |
string | null |
Required, always and in every mode. Ingress host and PAPERLESS_URL (https://<domain>), the only origin the release declares — the StatefulSet carries its own required() for it (style guide §12.1). Also the HTTPRoute's hostname while httpRoute.hostnames is empty. |
ingress.clusterIssuer |
string | null |
Required while ingress.enabled is true. cert-manager ClusterIssuer. It used to be required always, because there was no gate to free it; with the gate it is conditional, and in Gateway mode it is unused and unnecessary. |
ingress.className |
string | nginx |
Ingress class. No counterpart in Gateway mode — the class sits on the Gateway. |
ingress.annotations |
object | {} |
Merged with the cert-manager and the two body-size annotations the chart always sets. No counterpart in Gateway mode: the Gateway API has no annotation channel, so these are gone there — the chart's own upload cap included. |
TLS is stored in tls-<release>-paperless-ngx-ingress.
httpRoute
The Gateway-API alternative to the Ingress, off by default. Added with group 2 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart (style guide §4).
| Key | Type | Default | Description |
|---|---|---|---|
httpRoute.enabled |
bool | false |
Renders <release>-paperless-ngx-httproute instead of the Ingress. Mutually exclusive with ingress.enabled — with both true the render aborts naming both values, and ingress.enabled defaults to true here. |
httpRoute.parentRefs |
list | [] |
The Gateway(s) the route attaches to — Gateway API ParentReference entries (name, and optionally namespace, sectionName, port, group, kind), passed through unchanged. Required while httpRoute.enabled is true: the render aborts on an empty list. The schema requires name on every entry and is otherwise left open, because Kubernetes owns that schema and adds fields to it across releases. |
httpRoute.hostnames |
list | [] |
Empty means [ingress.domain], so the hostname has a single source of truth. A hostname set here does not move PAPERLESS_URL with it — Django keeps trusting the origin built from ingress.domain, and every login POST from the new name is refused as a CSRF failure until that value is changed too (and the pods restarted). |
What the chart renders in this mode is only the HTTPRoute: one rule, PathPrefix: /, one backendRefs entry pointing at <release>-paperless-ngx-service on port 80 — the port number, where the Ingress references the same hop by name (pless-s-port). Three names describe that hop in this chart, which is why the number is held by ci/run-httproute-match-test.sh rather than by review.
paperless
| Key | Type | Default | Description |
|---|---|---|---|
paperless.replicas |
int | 1 |
Replica count; 0 is allowed. See One replica, one volume before raising it. |
paperless.busybox.image.repository |
string | busybox |
Auxiliary image for the /run-ownership init container and the SMB consume mover. Pinned independently of appVersion; reviewed on every app-update round (Renovate). |
paperless.busybox.image.tag |
string | 1.38.0 |
|
paperless.ocr.languages |
string | eng |
PAPERLESS_OCR_LANGUAGE, singular. The plural variable that makes the image fetch packages at start is deliberately not set — see OCR languages come with the image. |
paperless.ocr.mode |
string | auto |
auto | redo | force | off. The 2.x values skip/skip_noarchive were removed upstream; skip maps to auto. |
paperless.ocr.archiveFileGeneration |
string | auto |
auto | always | never. PDF/A generation, decoupled from OCR in 3.0 (always is the 2.x behaviour). |
paperless.ocr.rotate_pages |
string/bool | 'true' |
PAPERLESS_OCR_ROTATE_PAGES. |
paperless.ocr.output_type |
string | pdfa |
PAPERLESS_OCR_OUTPUT_TYPE. |
paperless.files.filenameFormat |
string | {{ created_year }}/{{ correspondent }}/{{ title }} |
PAPERLESS_FILENAME_FORMAT (paperless's own placeholder syntax, not Helm's). |
paperless.files.storage |
string | 20Gi |
Size of the main PVC only (data/, media/, export/ as subPaths). Not null-safe on purpose: an erased value yields an invalid PVC instead of a degraded workload. |
paperless.files.maxUploadFileSize |
string | 10m |
Rendered into both nginx.ingress.kubernetes.io/proxy-body-size and nginx.org/client-max-body-size. Applies to web uploads only. |
paperless.files.barcodes.enabled |
bool | true |
Barcode handling. Only an explicit false disables it — see A childless block means "defaults". |
paperless.files.barcodes.barcodeString |
string | PATCHT |
Separator barcode. |
paperless.files.barcodes.retainBarcodePage |
bool | false |
Keep the separator page in the split documents. |
paperless.files.barcodes.asn.enabled |
bool | true |
Archive-serial-number barcodes. |
paperless.files.deleteDuplicates |
bool | false |
true restores the 2.x behaviour of deleting a document that matches one already in the archive. |
paperless.files.tikaGotenberg.enabled |
bool/string | false |
Office-document conversion. The schema accepts a string because live bundles write "true"; the template gates on truthiness, so the string "false" still renders the block. Use a real boolean false to switch it off. |
paperless.files.tikaGotenberg.tikaEndpoint |
string | null |
Required while the block is on. Bare host:port — see The Tika endpoints carry no scheme. |
paperless.files.tikaGotenberg.gotenbergEndpoint |
string | null |
Required while the block is on. Bare host:port. |
paperless.scripts.preConsumptionScripts |
list | [] |
Renders no object and changes no behaviour — see paperless.scripts does nothing. |
paperless.scripts.postConsumptionScripts |
list | [] |
As above. |
paperless.livenessProbe |
object | GET /:8000, period 30s, timeout 5s, 3 failures |
|
paperless.readinessProbe |
object | GET /:8000, period 10s, timeout 5s, 3 failures |
|
paperless.startupProbe |
object | GET /:8000, period 10s, 60 failures (= 600s) |
Gates liveness and readiness. The budget covers migrations and, on the first 3.x start, the Whoosh → Tantivy index rebuild. |
paperless.env |
list | [] |
Extra environment variables appended to the chart's own — paperless reads ~80 PAPERLESS_* variables and the chart maps a handful explicitly, so everything else goes here. Rendered through tpl; value, secretKeyRef and configMapKeyRef forms, quoted since 13.0.0 (before that a *-leading value aborted the render and "true" arrived as a bool). Additive, not overriding — see Environment variables and Moving the log off the data volume. |
paperless.resources.limitFactor |
number | 3 |
Memory and ephemeral-storage limits are computed as factor × request. |
paperless.resources.requests |
object | cpu: 0.2, memory: 1024Mi, ephemeralStorage: 200Mi |
|
paperless.resources.limits |
object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
paperless.securityContext.pod |
object | non-root 1000/1000, fsGroup: 1000, fsGroupChangePolicy: Always |
Passed verbatim into the pod spec and read a second time by the init container, which chowns /run to runAsUser:runAsGroup. |
paperless.securityContext.container |
object | readOnlyRootFilesystem: true, all caps dropped, no privilege escalation, non-root |
Passed verbatim into the main container. Only /tmp and /run (emptyDirs) and the PVCs are writable. |
smbConnection
| Key | Type | Default | Description |
|---|---|---|---|
smbConnection.enabled |
bool | false |
Renders the SMB PV, PVC, OnePasswordItem and the consume CronJob. |
smbConnection.server |
string | null |
Share address, used as //<server> and as the PV's volumeHandle. See The SMB PersistentVolume outlives the release. |
smbConnection.cronJob |
string | 0 0,12 * * * |
Cron schedule. Any notation works since 13.0.0 — quote it or not, the chart quotes it on the way out. Before that a schedule starting with * or @ aborted the render; see Any cron notation works now. |
smbConnection.cron.pinToServerNode |
bool | true |
Runs the mover on the node of the StatefulSet pod via a required podAffinity. Without it Longhorn refuses to mount the shared RWX consume volume on the StatefulSet's node. false restores the old placement and its mount errors — see The mover runs where the server runs. |
smbConnection.cron.startingDeadlineSeconds |
int | 600 |
How late a missed run may still start. Also keeps the CronJob controller away from its "too many missed start times (> 100)" stop. |
smbConnection.cron.activeDeadlineSeconds |
int | 3600 |
Hard stop for one run, counted from the job's start — so it also bounds a pod that never gets scheduled. Raise it if a run legitimately moves more than an hour's worth of files. |
smbConnection.cron.env |
list | [] |
Extra environment variables for the mover container, same form as paperless.env. Present for the convention rather than for a need: the container is a busybox running one mv. The schedule's time zone is the controller's and no TZ here changes it. |
smbConnection.cron.securityContext.pod |
object | non-root 1000/1000, fsGroup: 1000, fsGroupChangePolicy: Always |
The mover only shuffles files between two volumes, so it runs fully hardened as the same uid as paperless. |
smbConnection.cron.securityContext.container |
object | readOnlyRootFilesystem: true, all caps dropped, no privilege escalation, non-root |
|
smbConnection.cron.resources.limitFactor |
number | 3 |
|
smbConnection.cron.resources.requests |
object | cpu: 10m, memory: 16Mi, ephemeralStorage: 10Mi |
|
smbConnection.cron.resources.limits |
object | unset |
oidc
| Key | Type | Default | Description |
|---|---|---|---|
oidc.enabled |
bool | false |
Adds the allauth OpenID-Connect provider. PKCE-only, no client secret. |
oidc.autoLoginWithProvider |
bool | false |
PAPERLESS_REDIRECT_LOGIN_TO_SSO — skips the app's own login form. |
oidc.disableRegularLogin |
bool | false |
PAPERLESS_DISABLE_REGULAR_LOGIN — removes it. See Locking yourself out with OIDC. |
oidc.provider.id |
string | null |
Required while oidc.enabled is true. Appears in the redirect URI. |
oidc.provider.name |
string | null |
Required while oidc.enabled is true. Label of the login button. |
oidc.provider.clientId |
string | null |
Required while oidc.enabled is true. Travels as plain env, not through a Secret. |
oidc.provider.discoveryUrl |
string | null |
Required while oidc.enabled is true. The provider's .well-known endpoint. |
oidc.provider.tokenAuthMethod |
string | null |
Explicit token-endpoint auth method. Restricted to none since 13.0.0 (the schema rejects everything else). Some providers need the public client spelled out since the allauth update in 3.0; the symptom without it is a login dying at the callback with invalid_client. The old comment named client_secret_basic, which cannot work here — see No client secret, and no value that pretends otherwise. |
valkeySubchart and valkey (subchart)
The subchart is this repo's own valkey chart, resolved from the published index — not upstream's. So §14 point 2 ("we consume their chart as it comes") does not apply: there is no third party's defaults to leave alone, because the subchart follows our conventions by being one of ours.
| Key | Type | Default | Description |
|---|---|---|---|
valkeySubchart.enabled |
bool | false |
true deploys our valkey chart alongside, reachable at <release>-valkey-service:6379. Must not be erased — see below. |
valkey.* |
object | see below | Passed through to the subchart — these are its value names, not this chart's. |
The switch is valkeySubchart.enabled, not valkey.enabled, and that is forced. Everything under valkey: is handed to the subchart as its values and validated against its schema, and our valkey chart ships additionalProperties: false with no enabled property, so valkey.enabled is rejected by the subchart:
valkey: - at '': additional properties 'enabled' not allowed
And because Helm skips a disabled subchart's schema validation, that error would surface only for whoever first set it to true — the default path renders perfectly clean.
valkeySubchart: null is rejected, and that is the opposite of this chart's other blocks. Everywhere else an erased block means "the defaults apply" (§12, issue #99). Here it cannot: a null override deletes the key, Helm then fails to resolve the condition: path, and an unresolvable condition counts as enabled — so erasing the switch turns the subchart on, with no password set. Measured for Gitea #78. values.schema.json therefore requires the key, and the message arrives before anything renders:
- at '': missing property 'valkeySubchart'
An empty block (valkeySubchart: {}) is fine: Helm merges that with the chart default.
| Key | Default | Description |
|---|---|---|
valkey.secrets.authSecretRef |
null |
Required once the subchart is on. Set it by hand to the same op:// path as secrets.redisSecretRef — the auth-conf field lives in that item. One field, holding the complete line requirepass <password>, not a bare password. |
valkey.persistence.enabled |
true |
Queue, not cache — see below. The subchart's own default. |
valkey.persistence.appendonly |
true |
AOF, so the loss window on a crash is the fsync interval and not the gap since the last snapshot. |
valkey.persistence.size |
1Gi |
Sized for a task backlog and its results — the documents are on paperless' own PVC. A PVC can grow, never shrink. |
valkey.valkey.maxmemoryPolicy |
noeviction |
Not restated in our values; it stays at the subchart's default, which is the queue half of the pair. |
valkey.persistence.storageClass |
longhorn-encrypted-ns |
The subchart default, left alone: queue payloads are the same documents that are in the database. |
valkey.networkPolicy.allowedNamespaces |
[] |
The subchart's own policy, on by default and allowing the release's own namespace — which with a subchart is where paperless runs. Needs an entry only across namespaces, which a subchart rules out. |
valkey.secrets.authSecretRef cannot default to secrets.redisSecretRef, and that is Helm, not an omission. A values.yaml is never templated and a parent chart cannot compute a subchart's value — values are merged statically before any template runs. Writing "{{ .Values.secrets.redisSecretRef }}" there does not resolve; it lands verbatim in the subchart's itemPath and fails as a YAML parse error pointing at the subchart's template, naming neither the value nor the cause. So the path is typed twice. Measured for Gitea #78, which is also where the alternatives were weighed and rejected.
Forgetting it is caught by the schema, not by a fail() in a template — and the reason is worth knowing, because it generalises to every parent chart here. Helm renders a subchart's templates before the parent's, so a guard in this chart always loses the race to the subchart's own required(), whose message reads You must provide an auth Secret Reference (secrets.authSecretRef) — the wrong path from the parent's point of view, and it cannot know the item is the Redis one. Schema validation runs earlier than any rendering, so the rule lives there and reports the real path:
- at '': 'allOf' failed
- at '/valkey/secrets/authSecretRef': got null, want string
The first draft of that check was a fail() in _helpers.tpl called from a rendered template. It was removed rather than shipped once the race was measured: a check that cannot fire is exactly the mistake found while testing stalwart (Gitea #77).
It is configured as a QUEUE, which is the opposite of what stalwart chose, and the comparison is the useful part. The valkey chart treats persistence and eviction as a pair and warns against mixing them: queue is persistence.enabled: true + noeviction, cache is persistence.enabled: false + allkeys-lru. Read off the paperless-ngx 3.2.1 source rather than assumed, one PAPERLESS_REDIS url serves five consumers:
| Consumer | Setting | Character |
|---|---|---|
| Celery broker | CELERY_BROKER_URL |
queue — consume, OCR, index updates, classifier training, scheduled tasks |
| Celery result backend | CELERY_RESULT_BACKEND, CELERY_RESULT_EXPIRES 3600 |
state of running tasks; Redis in 3.x, the database before |
| Websocket channel layer | channels_redis.pubsub, expiry 15s |
pub/sub, not stored keys |
Django cache default |
django.core.cache.backends.redis.RedisCache |
recomputable |
| Cachalot read cache | PAPERLESS_DB_READ_CACHE_ENABLED (default off), TTL 3600 |
recomputable |
allkeys-lru would not be a trade here but actively wrong: LRU evicts across the whole keyspace with no regard for key type, and a backlog of waiting consume tasks is exactly what it reads as cold — written once, read later. So a cache configuration would preferentially throw away the work and keep the cache entries. A dropped consume or index task means a document that is silently never processed: the file stays in the consume directory although the watcher has already fired. An evicted Celery result means a task that shows as "started" in the UI forever.
The price, stated rather than smoothed over: two of the five consumers are caches and they get the policy that does not suit them. When the instance fills, noeviction makes Valkey refuse writes — cache writes and new tasks alike — so paperless starts erroring instead of quietly degrading. That is the loud direction and therefore the right one, but it is a price. The answer is to size memory for the working set (valkey.valkey.resources.requests.memory), never to switch the policy. What keeps the price small: all three cache-like consumers are TTL-bounded (15s, 3600s, 3600s), so they bound themselves and do not need eviction to stay in check, and the read cache is off by default.
scaleDown and checksum/config stop at the subchart boundary. scaleDown: true scales paperless to zero and leaves the Valkey running — which is the right way round for a queue, because the backlog stays where it is. And a new valkey chart release reaches a cluster only when paperless-ngx is republished.
networkPolicy
Inbound 8000 only. Outbound DNS, TCP 5432 (Postgres), 6379 (Redis/Valkey), 443, and — only while paperless.files.tikaGotenberg.enabled is true — 9998, 3000 and 80 for Tika and Gotenberg. With networkPolicy.egress.mailImport on, also 993 (IMAPS).
Chart versions up to and including 14.0.0+up3.3.1 named only 80 in that rule, and it never matched — see Why that rule names 9998 and 3000, not the Services' 80. On such a release with Tika enabled the repair is networkPolicy.egress.extraPorts: [9998, 3000]; the fix is chart 14.1.0+up3.3.1.
| Value | Type | Default | Meaning |
|---|---|---|---|
networkPolicy.enabled |
bool | true |
Renders the policy. Only a boundary where the CNI enforces one — unenforced, the API server accepts it and nothing is filtered, so everything reads as configured. |
networkPolicy.allowedNamespaces |
list | [] |
Namespaces whose pods may reach the restricted ports, additionally to the release's own. Matched on the kubernetes.io/metadata.name label Kubernetes sets itself — no labelling work, and no typo protection either: a name that does not exist matches nothing and is silently ineffective. Namespace-granular, so an entry opens the port to every pod in it. |
networkPolicy.probeSources |
list | [] |
CIDRs allowed to reach the probed port. Empty means open, not blocked — the rule is then rendered with no from at all. Probes come from the IP of the worker node running the pod, which the chart cannot know; a list that misses it drops every probe and the pod never becomes Ready. |
networkPolicy.ingressController |
object | kube-system / app.kubernetes.io/name: rke2-traefik |
The controller allowed to reach the routed port once probeSources restricts it. The two keys render as one from entry, i.e. an AND — which is why this is not a line in allowedNamespaces, where it would open the port to every pod in kube-system. Default measured on the cluster, not assumed. |
networkPolicy.egress.mailImport |
bool | false |
Opens outbound 993 (IMAPS) for paperless' mail import. A value and not a derivation, because the mail accounts are rows in the database, entered in the web UI — nothing in the values says whether a release fetches mail. 993 only: plain IMAP (143) and POP3 (110, 995) are deliberately not covered and go into extraPorts. Unlike the Tika rule this is the real destination port, because a mail server outside the cluster is reached with no Service and so no destination NAT in the path. |
networkPolicy.egress.extraPorts |
list | [] |
Additional outbound TCP ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off. For a destination inside the cluster these are pod ports, not Service ports — see the note below. |
Operating notes
Routing: Ingress or Gateway API
Two mutually exclusive modes, and exactly one of them may be on. ingress.enabled ships true, httpRoute.enabled ships false.
This chart gained ingress.enabled with the Gateway mode, and the reason is worth knowing. It was the last chart in the repository rendering its Ingress unconditionally. Without a second value path there is nothing for the mutual-exclusion abort to name — and worse, the Ingress together with its unconditional required() on clusterIssuer would have kept rendering in Gateway mode: a value demanded for something the chart does not produce, which is exactly the defect §12 exists to remove. The switch defaults to true, so an existing release is unaffected and renders byte-identically; the practical consequence is only that Gateway mode needs ingress.enabled: false written out.
| Rule | Why it is a hard failure and not a default |
|---|---|
ingress.enabled and httpRoute.enabled cannot both be true — the render aborts naming both value paths |
two data paths onto one Service under one hostname is a conflict at DNS level already: one name, two addresses, and which one a client reaches is luck. A migration runs over a second hostname, never in parallel. The abort message names the true default explicitly here, because setting httpRoute.enabled alone is the likely mistake. |
httpRoute.parentRefs must not be empty — the render aborts |
a route without a parent attaches to no Gateway. The object is created, it is schema-valid, no controller rejects it, and it routes nothing: no event, no condition, and the only symptom is a hostname that does not answer. |
| the mode is a value, never auto-detected | .Capabilities.APIVersions is unreliable at template time, and an HTTPRoute without the CRDs fails at install time. A chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing. |
What moves to the platform. In Gateway mode the chart renders no Gateway, no gatewayClassName, no Certificate and — deliberately — no implementation-specific policy object (ClientSettingsPolicy, ClientTrafficPolicy and their equivalents). Only HTTPRoute with parentRefs looks identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, and keeping to that subset is what lets the cluster's implementation be chosen, or replaced, without touching the chart again. Class and certificate therefore belong to the Gateway and its listeners, which live in cluster-config.
ingress.domain is required in this mode too, and it now says so itself. It is PAPERLESS_URL, the only origin this release declares to Django. That read used to rely on the Ingress template's required() firing first — which stopped being guaranteed the moment the Ingress became switchable, so the StatefulSet carries its own required() now. Without it, httpRoute.hostnames set and ingress.domain empty would have rendered https:// and refused every login POST as a CSRF failure, with nothing in the log naming a chart value. Same consequence in the softer case: a hostname in httpRoute.hostnames that ingress.domain does not carry means pages load and logins fail. Set both together, and remember the pods restart before they notice a changed domain.
The web-UI upload cap becomes the Gateway's; the consume directory is untouched. paperless.files.maxUploadFileSize exists only as an ingress-nginx annotation, and the Gateway API has no counterpart — it knows no request-body size at all, that is implementation-specific policy, and this chart deliberately renders no policy object. So in Gateway mode the web-UI cap is whatever the Gateway enforces, and the error comes from the proxy rather than from paperless. The second intake route is unaffected in every mode: the consume directory passes no proxy and has no size limit at all, so a document too large for the web UI still gets in that way.
Whether the route attached at all is only visible in its status, because no Kubernetes object reports a route that attaches to nothing:
kubectl -n <namespace> get httproute <release>-paperless-ngx-httproute -o yaml
Accepted: True and ResolvedRefs: True under status.parents[] is a working attachment. An empty status means no controller has looked at the route at all — usually a parentRefs pointing at a Gateway that does not exist, or a listener whose allowedRoutes does not admit this namespace.
Redis fails quietly, Postgres does not
Without the database paperless does not come up at all — and all three probes ask for / on port 8000, so you get no useful error: the pod never turns Ready and the startup probe kills it after 600s. Read the container log, not the pod events.
Without Redis the web UI comes up and looks healthy. Redis is the Celery broker: consumption, OCR, the scheduled tasks and the index updates simply never run, and documents dropped into the consume directory stay there. A wrong redis-connection-url costs the whole processing side and nothing turns red. With the subchart on, that url is in your hands rather than somebody else's, which is exactly why it is the first thing to check.
The bundled Valkey: one step of it is in the vault
With valkeySubchart.enabled: true the release contains a Valkey of its own — StatefulSet <release>-valkey-sfs, Service <release>-valkey-service, its own NetworkPolicy and its own PVC. Three things are operator work and none of them fails loudly on its own.
1. The connection url must be repointed at it. The chart cannot write it; it lives in the vault. In the item named by secrets.redisSecretRef, field redis-connection-url:
redis://:PASSWORD@<release>-valkey-service:6379
Leaving it pointing at an external Redis is not an error anywhere: paperless talks to that one, the bundled Valkey runs along unused, and nothing reports it. NOTES.txt prints the exact name for the release at hand.
2. The password is in the item twice — once inside redis-connection-url and once in the requirepass PASSWORD line of auth-conf. A rotation has to touch both fields; change only one and paperless can no longer authenticate against its own Valkey. The alternative — assembling the url from parts — would break the secret contract of every running release (a major) and route the password through a template. Deliberately not chosen; the trap is named instead. After any such change, restart both workloads by hand, because Helm cannot see inside a 1Password item:
kubectl -n <namespace> rollout restart statefulset/<release>-paperless-ngx-sfs
kubectl -n <namespace> rollout restart statefulset/<release>-valkey-sfs
3. A OnePasswordItem materializes every field of its item. With both CRs naming the same item, the Valkey's Secret therefore also carries redis-connection-url and key-prefix, and this chart's Redis Secret also carries auth-conf. Untidy, harmless — nothing mounts a field it does not reference. It is written down so nobody reads it as a leak or a bug and "tidies it up".
And one that is about the field's shape rather than about a step: auth-conf is a configuration line, not a password. The Valkey mounts it as a file and includes it, so the password never reaches a process list or an environment variable. An init container refuses to start the pod unless a line beginning with requirepass is really there — without that check a typo would leave a Valkey running with no password and looking perfectly healthy, because the official image compiles protected-mode off.
Never change valkey/ and paperless-ngx/ in the same pull request
The dependency resolves from the published index, not from the working tree, and valkey/ is published only by a push to main that touches it. A PR carrying both runs every helm command — lint, package, render and the install-test — against the old valkey .tgz, goes green about an artifact that is not the one in the diff, and leaves the change to valkey/ exercised by nothing. Two consecutive PRs: change valkey/, merge, let it publish, then raise the version: in paperless-ngx/Chart.yaml. The same rule is on the valkey page and in §14.
What a green pod does not prove
One container runs the web server, the Celery worker and the scheduler side by side under s6-overlay, and all three probes only ask the web server for /. A dead worker, a consumer that is not watching and a broken Redis connection all leave the pod Ready and the rollout green. The honest check is a document going in and coming out the other side, plus:
kubectl -n <namespace> logs <release>-paperless-ngx-sfs-0 | grep -i -e error -e celery
The root init container that hands over /run
s6-overlay refuses to start unless /run is owned by the runtime uid — group-write is not enough — while an emptyDir is always created root-owned. A minimal busybox init container therefore runs as uid 0 with only CHOWN added back, chowns the /run emptyDir to the pod context's runAsUser:runAsGroup, and exits. This is the documented deviation from the hardening baseline (style guide §5); the main container itself stays non-root with a read-only root filesystem.
A consequence of that read-only rootfs: anything the app would install at runtime — an extra tesseract language pack, for instance — cannot be written, no matter which variable asks for it.
How documents get in
Everything in /usr/src/paperless/consume is picked up by the consumer. That directory is its own volume, <release>-paperless-ngx-consume-volume-claim, and its size is not paperless.files.storage: the claim is hardcoded to 1Gi, ReadWriteMany, and both PVCs are hardcoded to storage class longhorn. Without SMB, the web UI is the only automatic route in; to hand a file over directly:
kubectl -n <namespace> cp <file.pdf> <release>-paperless-ngx-sfs-0:/usr/src/paperless/consume/
Web uploads take the second route and pass the Ingress, where paperless.files.maxUploadFileSize (default 10m) caps the body. A larger file is rejected by the ingress controller with a 413 before paperless ever sees it — the consume directory has no such limit.
In Gateway mode that value is not enforced at all: the Gateway API has no body-size field, so the cap is the Gateway's and the rejection is the Gateway's error. The consume directory is unaffected either way, which makes it the reliable route for a large document in both modes. See Routing.
The consume CronJob is the one probes exception
smbConnection.enabled: true adds <release>-paperless-ngx-consume-cronjob, which mounts the share and runs, once per firing:
find /mnt/smb -maxdepth 1 -name "*.pdf" → mv into the consume volume
Read that literally. Anything in a subdirectory is never touched, and anything that is not *.pdf — an image, an .eml, a .PDF in capitals — stays on the share forever with nothing reporting it. It is a move, not a copy: after the job has run, the only copy is on the consume volume.
The job has no probes — the single documented exception to the repo-wide probes rule (style guide §6) — and it cannot fail usefully either: with nothing to move it exits 0, indistinguishable from a successful run. Judge it by what arrives:
kubectl -n <namespace> get jobs --sort-by=.metadata.creationTimestamp
kubectl -n <namespace> logs job/<name-of-the-last-one>
scaleDown: true suspends the CronJob. paperless.replicas: 0 does not suspend it — but since 13.0.0 it stops the mover all the same, and for a different reason: the required podAffinity cannot be satisfied without a StatefulSet pod, so the job fails per schedule with DeadlineExceeded instead of running. The old hazard that stood here — PDFs moved off the share onto a consume volume nothing is reading, gone from the share and not yet in paperless — is therefore no longer reachable in that configuration, and what replaces it is a visible failing job. See The mover runs where the server runs; with pinToServerNode: false the old behaviour, and the old hazard, are back.
Any cron notation works now
Since 13.0.0. Until then smbConnection.cronJob was rendered into the manifest unquoted, so a schedule starting with * — */15 * * * *, the commonest notation there is — aborted the render:
Error: YAML parse error on paperless-ngx/templates/paperless-consume.cronjob.yaml:
error converting YAML to JSON: yaml: line 6: did not find expected alphabetic or numeric character
YAML read the leading * as an alias indicator, and quoting it in the values file did not help: Helm passes the string's content through, so the star stood bare in the manifest again. @daily failed the same way. The error named the template and the operator's own value appeared nowhere in it, which is the part that cost the time.
If you are carrying a workaround schedule from that era — 5,20,35,50 * * * * written because every-15-minutes would not render — you can now write what you meant. Fixed in Gitea #11 with | quote, and held by ci/run-yaml-scalar-render-test.sh on every PR; the overlay that feeds it a star-leading schedule is ci/yaml-scalar/paperless-ngx.yaml, needed because no fixture renders this CronJob at all. That is the whole reason the defect survived in a shipped chart. The transferable half is in Lessons learned.
The mover runs where the server runs
Since 13.0.0 the mover pod carries a required podAffinity on the StatefulSet pod's label with topologyKey: kubernetes.io/hostname (smbConnection.cron.pinToServerNode, default true).
Why: the mover mounts the consume volume, which is RWX and comes from Longhorn, and Longhorn's CSI driver refuses to mount an RWX volume unless it finds a workload pod in Pending. With the mover on a different node, the kubelet of the StatefulSet's node retried every two minutes:
MountVolume.SetUp failed for volume "pvc-…" : rpc error: code = Aborted
desc = no Pending workload pods for volume pvc-… to be mounted:
map[Running:[…-sfs-0] Succeeded:[…-consume-cronjob-…]]
291 events in 10 hours, in both releases with SMB on. The release without the CronJob produced none on identically built volumes, which is what identified the mover rather than the volume.
A static nodeSelector would have been the wrong tool: the StatefulSet pod is not pinned either and moves on every restart, so the selector would have been right until then and silently wrong after. preferred would have fallen back to another node in exactly the situation the rule exists for — the quiet version of the same bug.
What it costs, and the one case that is not covered. A required term cannot be satisfied while the StatefulSet pod is absent:
| Case | What happens |
|---|---|
scaleDown: true |
the CronJob is suspended outright, so no job is created |
| rolling restart | the mover waits and is scheduled once the StatefulSet pod is back; a running mover is never evicted (IgnoredDuringExecution) |
paperless.replicas: 0 with SMB still on |
the mover cannot be placed and the job fails after activeDeadlineSeconds with DeadlineExceeded — one visible failure per schedule instead of a Pending pod nobody sees |
concurrencyPolicy: Forbid and startingDeadlineSeconds come with it: two movers racing on the same mv is never wanted, and the deadline keeps the controller away from the state where more than 100 missed schedules stop it scheduling this job at all (reachable by leaving scaleDown: true in place for months, and reported only in the controller's own log).
What the pinning does not fix: the consume volume stays RWX with a share-manager behind it. Co-locating the two consumers removes the cross-node mount, not the second consumer. The design that removes it is running the mv inside the StatefulSet pod — which cannot be done for the existing installations, because RWX cannot be taken back off an existing PVC. Gitea #101 names it as the alternative rather than the proposal.
successfulJobsHistoryLimit is deliberately untouched. The three Succeeded pods in the message look like the cause and are not: Longhorn's predicate is the existence of a Pending pod, not the absence of finished ones. At 0 there would still be one Running pod and no Pending one, and the mount would still abort.
Not provable in CI: the install-test runs in k3d without Longhorn, so the mount failure cannot be reproduced there. What CI does hold is that the affinity renders and that its selector really matches the StatefulSet's pod labels (ci/run-selector-match-test.sh, extended for required podAffinity in Gitea #101) — the fix is demonstrable at the render and placement level, not at the symptom.
Moving the log off the data volume
PAPERLESS_LOGGING_DIR through paperless.env is the worked example of that block, and the reason it exists is not in this chart:
paperless:
env:
- name: PAPERLESS_LOGGING_DIR
value: /tmp/paperless-log
The data directory sits on an RWX volume that Longhorn exports over NFS via a share-manager, and paperless writes its log there with concurrent_log_handler, which takes a lock file before every write and rotation. File locks over NFS are unreliable, so the handler fails with RuntimeError: Cannot acquire lock after 20 attempts — measured at 45 % of the application log across all three installations. Pointing the log at a path under the /tmp emptyDir the pod already mounts takes it off the share and the noise stops. No extra volume is needed, and that the log does not survive a pod restart is no loss: paperless also writes to stdout, which is what kubectl logs reads.
This does not fix the RWX data volume, which is the actual root cause: the PVC asks for ReadWriteOnce and was provisioned ReadWriteMany, and taking RWX back means recreating the volume. That is a jk-cluster-config decision and is tracked there (#360); this chart's part is only being able to set the variable at all.
The SMB PersistentVolume outlives the release
<release>-paperless-ngx-smb-pv is cluster-scoped with reclaim policy Retain, and its volumeHandle is derived from smbConnection.server alone. Two releases pointing at the same server collide on that handle, and a retained PV survives helm uninstall and blocks the next install of the same name until it is deleted by hand.
One replica, one volume
The main volume is a plain ReadWriteOnce PVC shared by the whole StatefulSet, not a volumeClaimTemplate. A second pod usually stays Pending on Multi-Attach error for volume; where the storage class really is RWX and it does start, two consumers watch the same directory and two schedulers run the same periodic tasks.
Rotating a secret does not restart the pod
Helm never sees a OnePasswordItem's content, so it cannot hash it (style guide §9) — and this chart carries no checksum annotation at all, not even over the rendered item paths. Pointing a *SecretRef at a different item rolls the pods; editing a value inside the same item does not. After such a rotation:
kubectl -n <namespace> rollout restart statefulset/<release>-paperless-ngx-sfs
Rotating paperless-secret-key logs every session out. Rotating the database fields points the release at whatever that endpoint contains, empty or not, and the next start migrates it.
A childless block means "defaults"
An override that writes barcodes: with no children is YAML null. The chart merges the block onto its defaults instead of letting the childless key erase them (#61), so this does not switch barcodes off — only barcodes.enabled: false does. The same null-safe semantics apply to the probe, securityContext and resources blocks of both workloads (#99). See style guide §12.2.
The Tika endpoints carry no scheme
The chart prefixes http:// itself, so tikaEndpoint and gotenbergEndpoint are bare host:port. A value written with a scheme renders as http://http://…, every Office document fails to convert, and everything else keeps working. Neither service is part of this chart or checked at startup.
OCR languages come with the image
paperless.ocr.languages sets PAPERLESS_OCR_LANGUAGE — which language to use. The chart deliberately does not set the plural PAPERLESS_OCR_LANGUAGES that makes the image fetch further packages at start, and with the read-only root filesystem it could not work anyway: a language the image does not already carry needs a different image, not a different value.
paperless.scripts does nothing
The ConfigMap that would carry the pre/post-consumption scripts is disabled at the top of its own template ({{- if false }}), no volume mounts it, and neither PAPERLESS_PRE_CONSUME_SCRIPT nor PAPERLESS_POST_CONSUME_SCRIPT is set on the container. Both values render no object and change no behaviour; NOTES.txt says so out loud when either list is non-empty.
secrets.oidcSecretRef is gone
Removed in 13.0.0 (Gitea #11), from the values and from the schema. A release that still sets it no longer renders:
values don't meet the specifications of the schema(s) in the following chart(s):
paperless-ngx:
- at '/secrets': additional properties 'oidcSecretRef' not allowed
Upgrading: delete the key. Nothing else is needed, and nothing is lost — no template ever read it, no OnePasswordItem was created for it, and whatever the item held never reached the pod. It was the quiet kind of defect: it looked like successful configuration.
It is not coming back as a feature, and the reason is in paperless rather than in the chart. The entire OIDC configuration travels in one JSON environment variable (SOCIALACCOUNT_PROVIDERS = json.loads(os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"))) with no _FILE variant, and a secretKeyRef cannot substitute a fragment into a JSON variable. The honest alternatives would be the secret inline in the pod spec, or the whole JSON typed into the vault by hand — which would make every required() in the oidc block dead wood. The operational question behind it is answered by Gitea #83: Keycloak marks a client public per client, so no provider constraint forces a confidential client here.
No client secret, and no value that pretends otherwise
The chart registers a public client with PKCE and configures no client secret — it renders OAUTH_PKCE_ENABLED and no secret at all.
oidc.provider.tokenAuthMethod is therefore restricted to none since 13.0.0. Its old comment offered client_secret_basic as the example, and setting that asked the provider for an authentication this release cannot perform: the login died at the callback with invalid_client, while the two values read together — a secret ref beside a secret-based auth method — looked like a working setup. Both halves are fixed in the same round, because they were the same mistake seen twice.
null omits the key and lets allauth decide; none states the public client explicitly, which is what some providers need spelled out. Widening the enum for a future method that needs no secret would be a minor bump; allowing one that needs a secret is not on the table. Measured when the restriction landed: all three live instances run PKCE and none of them sets the value, so nothing in the cluster was affected.
Locking yourself out with OIDC
The chart enables PKCE and configures no client secret — the client has to be registered as one that can complete the flow without one. Register this redirect URI at the provider:
https://<ingress.domain>/accounts/oidc/<oidc.provider.id>/login/callback/
Nothing about OIDC is exercised before someone actually signs in, so a wrong discovery URL, client id or redirect URI surfaces as a failed login on a perfectly healthy pod. Before relying on autoLoginWithProvider or disableRegularLogin, make sure at least one local superuser still has a usable password and that Django's admin login at /admin/ still lets it in — that is the only way back when the provider is unreachable.
Port-forwarding and CSRF
ingress.domain is also PAPERLESS_URL, the only origin the release declares. A port-forward loads pages over http://localhost:8000, but the login POST is refused as a CSRF failure. Forward the port to look around, use the domain to log in, or add PAPERLESS_CSRF_TRUSTED_ORIGINS through paperless.env for as long as needed. Changing ingress.domain later changes PAPERLESS_URL with it, and the pods have to restart before they notice.
The same mechanism is the reason httpRoute.hostnames must not disagree with ingress.domain: a route published under a name Django does not trust produces exactly this picture — pages load, logins fail — except that nothing about it looks like a port-forward. See Routing.
Why that rule names 9998 and 3000, not the Services' 80
This page said the opposite until chart 14.1.0+up3.3.1, and the chart acted on it. It cost an outage on 2026-10-15 (Gitea #136). The sentence that stood here, kept because it reads perfectly and that is exactly why nobody checked it:
An egress rule names the port on the destination as the client dials it, and paperless dials those charts' Services, which listen on 80 and forward to the containers' own 9998 and 3000.
kube-proxy does a ClusterIP Service's destination NAT before the egress policy is evaluated at the pod's network interface. The policy therefore sees podIP:targetPort and never clusterIP:port — so an egress rule names the pod port, and these two Services forward 80 to 9998 and 3000:
cluster-tika-apache-tika-service port 80 -> targetPort tika-port = 9998
cluster-gotenberg-gotenberg-service port 80 -> targetPort gotenberg-port = 3000
What it looked like after rolling out 14.0.0+up3.3.1: mail import, EML, HTML and Office documents stopped, plain PDFs and images kept working, every pod stayed Ready, and the error was
httpx.ConnectTimeout: timed out
documents.parsers.ParseError: Could not parse content with tika server at http://…: timed out
The error type is the tell, and it is the part worth carrying to the next chart: a timeout, not connection refused. A missing service refuses; a dropped packet runs into the timeout. Nothing in any message points at a NetworkPolicy. The reason 5432, 6379 and 443 were unaffected is only that their Service port and pod port are the same number.
The rule now names 9998, 3000 and 80 — the 80 costs nothing and covers a foreign Tika that really does listen on 80, where 80 is the pod port. A Tika or Gotenberg on some other port still needs it in networkPolicy.egress.extraPorts, as a pod port.
It has no policy switch of its own, deliberately. The rule stays gated on the feature value paperless.files.tikaGotenberg.enabled. A second switch that can drift from the feature re-arms this exact outage: whoever enables Tika and forgets the policy switch is back here, with the same message that does not mention the policy. CI/CD now renders apache-tika and gotenberg alongside this chart and asserts that this rule contains their containerPorts — the one assertion a single-chart render cannot make, and the reason this was not caught the first time.
Mail fetching is not permitted by default
Paperless' mail accounts are rows in its database, configured in the web UI, so this chart cannot see them and cannot derive the rule. A release that fetches mail sets networkPolicy.egress.mailImport: true for IMAPS (993); plain IMAP (143) and POP3 go into networkPolicy.egress.extraPorts. Left off on a release that does fetch mail, a mail rule silently stops importing, with a healthy pod behind it.
993 is the real destination port, unlike the Tika rule above: a mail server outside the cluster is reached with no Service and so no destination NAT in the path. What decides whether an egress rule needs the Service port or the pod port is that NAT — not the direction, and not the protocol.
Two pods of this release are not covered by this object
- The
valkeysubchart's pod carries its own labels, so this policy does not select it. It needs no cover from here: thevalkeychart ships its own NetworkPolicy, and a tighter one — Ingress-only with 6379 restricted by source, which it can do because its probes areexecprobes. - The consume CronJob's pods have a pod template with no labels at all, so no
podSelectorcan match them and nothing here restricts them. That gap is real. It is tolerable only because the job moves files between mounted volumes and reaches the SMB share through a CSI volume the node mounts — the pod opens no connection of its own. If that ever changes, the CronJob needs labels before it can need a policy.
The egress ports are the standard ones, not your configured ones
A NetworkPolicy matches CIDRs and selectors and never hostnames, and this cluster's CNI (Canal — Flannel plus Calico) has no FQDN rule, so an egress rule can only ever name a port and not a destination.
Worse, the chart does not even know the port: the connection details arrive inside 1Password items it never parses. So the rules name the conventional ports, and a dependency listening anywhere else is blocked. Put the real port in networkPolicy.egress.extraPorts.
And for a dependency inside the cluster, "the real port" is the port its pod listens on, not the port of its Service — see the Tika note above. Reading it off a kubectl get svc column is the mistake that caused #136; the number to use is the Service's targetPort, resolved to the containerPort behind it. Outside the cluster there is no translation and the port dialled is simply the port.
The probed port is open inside the cluster by default
networkPolicy.probeSources is empty out of the box, so the rule for the port the probes address is rendered with no from selector at all — any pod the CNI routes to can reach it. Not from the internet (the port carries no NodePort), but this is not pod-level isolation, and reading the object as if it were is the mistake to avoid.
It is that way on purpose. The kubelet's liveness, readiness and startup probes originate from the IP of the worker node running the pod — an address no chart can know and must not guess. A CIDR that excludes the real node blocks every probe: the pod never becomes Ready, the rollout times out, and the symptom reads as a broken application rather than as a policy. Shipping open is the honest default; narrowing it means listing the worker node IPs yourself.
Note the companion trap: the moment probeSources is non-empty the rule is restricted, so everything else that needs the port has to be in the same rule. The chart adds the release's own namespace, ingressController and allowedNamespaces for exactly that reason.
The policy is only a boundary where the CNI enforces one
The API server accepts a NetworkPolicy whatever the cluster does with it. Where the CNI does not enforce policies, everything looks configured and nothing is filtered — which is worse than no policy, because it invites the assumption that something is being filtered. Verify by connecting from an unrelated namespace rather than by reading it off the manifest.
Measured for this cluster: the CNI is Canal (Flannel for the network, Calico for policy), 6/6, and it does enforce. Measured for CI: k3s brings its own policy controller and enforces too — but it exempts the kubelet's probe traffic, so a blocked probe path does not turn the install-test red. What CI does catch is anything the application itself needs.
Upgrades and backup
- Back up before every app-version bump, and back up both halves. Postgres holds the metadata, the tags and one row per document;
<release>-paperless-ngx-volume-claimholds the files underdata/,media/andexport/. Restoring one without the other gives an archive full of references to documents that are not there. - Migrations are one-way.
helm rollbackputs the old image back on a database the new one has already rewritten. - Raise
paperless.startupProbe.failureThresholdbefore a large jump or a first import, not after the crash loop has started: every start runs migrations before the server answers, the first 3.x start also rebuilds the search index from scratch, and a restart lands in the middle of it. - Both PVCs are chart objects, so
helm uninstalldeletes them and the documents with them unless the reclaim policy says otherwise. The external database survives — which makes an accidental uninstall look recoverable while the files are already gone. - App major jumps go stepwise: last release of the old major line first, then the newest major (Versioning).
CI
ci/paperless-ngx/ holds test-values.yaml (op:// refs against the stub CRD, paperless.ci.local, and a deliberately childless barcodes: key as the regression guard for #61), fixtures.yaml (throwaway Postgres, the dummy secrets, a no-provisioner longhorn StorageClass with pre-bound hostPath PVs, and a node-setup Job that pre-creates the exact subPath directories world-writable because hostPath volumes ignore fsGroup) and settings.env (INSTALL_TIMEOUT=600 for the first-start migrations). smbConnection and oidc stay off in CI. The install-test runs on every PR that touches the chart — see CI/CD.
paperless.files.tikaGotenberg.enabled is off in that fixture too (the chart default), so the install-test installs paperless without Tika and Gotenberg and never carries the egress rule of #136 at all. That path is therefore not proven end-to-end in CI: what CI proves is the render and the cross-chart assertion, and enforcement of the corrected rule was proven in the cluster instead. Worth knowing before reading a green install-test as cover for it.
The subchart is ON in CI, and the throwaway Redis Deployment is gone — this is the first real installation of the valkey-subchart path anywhere in this repository. stalwart added the same dependency first but covered it by a local render only, deliberately: its in-memory store is a database object, so a Valkey in that namespace would have been reachable and unused and the test would have looked as though it covered clustering. Here it is the opposite — paperless reads PAPERLESS_REDIS from the vault item, so pointing that url at ci-valkey-service makes the bundled Valkey the broker the release actually uses. What the run proves beyond the previous one: the subchart's StatefulSet becomes Ready inside a parent release (so its init container found the requirepass line and its three authenticated valkey-cli probes answered PONG), its PVC path works under a parent release name, and the names really are <release>-valkey-<kind>. A wrong Service name or a password mismatch between the item's two fields is a red run rather than a quietly idle Valkey.
Two render-only overlays switch smbConnection on, and they are the only thing in the repository that renders the consume CronJob. The install-test fixture keeps SMB off (no SMB CSI driver on the runner), so until Gitea #11 that object existed in the chart and in no render — which is exactly how an unquoted schedule shipped for months. ci/yaml-scalar/paperless-ngx.yaml renders it with a star-leading schedule and with hostile env values for ci/run-yaml-scalar-render-test.sh; ci/selector-match/paperless-ngx.yaml renders it plainly so ci/run-selector-match-test.sh can hold the mover's required podAffinity against the StatefulSet's pod labels. They are kept apart on purpose: folding them would make each test's failure depend on the other's fixture. Neither reaches the symptom of Gitea #101 — k3d has no Longhorn — so the placement fix is covered at the render level and the mount failure is not reproducible in CI.
The install-test stays in Ingress mode, and here that was the easy call. The install runs exactly one configuration per chart, and this chart's fixture is the only installation of the valkey-subchart path anywhere — switching it into Gateway mode would have traded that coverage, plus the Ingress, for a CRD schema cyberchef already exercises against the real Gateway-API CRDs. Instead ci/httproute/paperless-ngx.yaml is a render-only overlay that switches this chart into Gateway mode for ci/run-httproute-match-test.sh, which asserts that every backendRefs entry names a Service and a port the same render creates and that parentRefs is not empty. A chart that has an overlay and renders no route fails that test. Neither half proves that traffic flows: no Gateway controller runs in CI.
Both fixture Secrets carry all three fields, because in a real cluster both OnePasswordItems name the same item and the operator materializes every field of it. Trimming each to what its consumer reads would have been a fixture tidier than reality, hiding the one thing worth knowing. The Valkey PVC uses local-path (k3d's provisioner): the chart's longhorn-encrypted-ns does not exist on the runner, and the pre-bound hostPath PVs of the longhorn stand-in would be worse, since the kubelet applies no fsGroup to a hostPath volume and the ownership path this test exists for would silently stop being exercised.
What it still does not prove: that documents are processed. That needs a real consume run and is out of scope here, as before.
See also
- Chart style guide — the rules this chart implements, and the two exceptions it holds
- valkey — the chart this one takes as an optional subchart, and the one to install separately when it should outlive this release
- stalwart — the same subchart configured as a cache instead of a queue; the two pages together are the worked example of that pair
- Versioning — what a bump of
12.1.1+up3.1.0means - Renovate — how
appVersion, the busybox image and the pinned subchart version are updated - Lessons learned
- Chart index
helm-charts wiki
How we work
How it is built
How charts are written
Charts
- apache-tika
- bentopdf
- bulwark
- calibre-web-automated
- convertx
- cyberchef
- freesailarr
- garage
- gotenberg
- homeassistant
- keycloak
- matrix-synapse
- minecraft-router
- minecraft-server
- outline
- paperless-ngx
- patchmon
- rallly
- rss-bridge
- samba
- satisfactory
- searxng
- stalwart
- sure
- teamspeak-server
- template-chart
- urlaubsverwaltung
- valkey
- voucher-vault
- ytdlp-web-player
- zipline