# Scale the whole chart down without uninstalling it: scaleDown: true renders # every Deployment/StatefulSet with replicas: 0 (and suspends CronJobs) and # takes precedence over any per-workload replicas value. PVCs, Secrets and # Services stay in place; scaleDown: false brings the workloads back up. scaleDown: false secrets: # op:// path of the item holding the Redis/Valkey connection. Fields: # "redis-connection-url" and "key-prefix" always, plus "auth-conf" when the # bundled Valkey subchart is used - see the valkeySubchart block at the end # of this file. NOTES.txt prints all three. redisSecretRef: null databaseSecretRef: null paperlessSecretRef: null smbConnectionSecretRef: null # REMOVED IN 13.0.0: secrets.oidcSecretRef (Gitea #11). It is gone from the # schema too, so a release that still sets it now fails to render with # additional properties 'oidcSecretRef' not allowed # instead of being ignored - DELETE THE KEY from your values, nothing else is # needed. It never did anything: no template read it, no OnePasswordItem was # created for it, and whatever the item held never reached the pod. # # It is not coming back as a feature, and the reason is in paperless itself: # the whole OIDC configuration travels in ONE json environment variable # (SOCIALACCOUNT_PROVIDERS = json.loads(os.getenv( # "PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"))), and there is no _FILE variant # of it. A secretKeyRef cannot substitute a fragment INTO a json variable, so # an honest implementation would have to either inline the secret in the pod # spec or push the complete json into the vault by hand - and the second one # makes every required() in the oidc block below dead wood. # # The operational question behind it is answered rather than dodged: this # chart registers a PUBLIC client with PKCE, Keycloak (Gitea #83) can mark a # client public per client, and so no provider constraint forces a # confidential client here. See the oidc block below. ingress: # Set to false to omit the Ingress entirely. NEW in the chart version that # added the httpRoute block below (Gitea #114): this chart used to render its # Ingress unconditionally, which is why it has no issue-#93 switch like the # rest. The default is true, so an existing release is unaffected. # # It was not added for uniformity. Without it the Ingress would keep # rendering in Gateway mode, and with it the required() on clusterIssuer - # a value demanded for something the chart then does not render. With the # switch that required() is conditional, as everywhere else here. # # clusterIssuer becomes unnecessary with the Ingress off. ingress.domain does # NOT: it is also PAPERLESS_URL, the only origin this release declares to # Django, and it carries its own required() in the StatefulSet. # # Mutually exclusive with httpRoute.enabled below: with both true the render # aborts, naming both values. Mind that enabled defaults to TRUE here, so # turning the Gateway mode on means switching this off in the same values # file. enabled: true clusterIssuer: null # The public hostname of this release. Read by the Ingress, by PAPERLESS_URL # in the StatefulSet (with its own required()), and - when # httpRoute.hostnames is empty - by the HTTPRoute. Required in every mode. domain: null className: nginx # Extra annotations for the Ingress; merged with the cert-manager # cluster-issuer and upload-size annotations the chart always sets. # # THESE HAVE NO COUNTERPART IN GATEWAY MODE. The Gateway API has no # annotation channel for controller behaviour, so anything configured here is # simply gone when httpRoute.enabled is used instead - it is then the # Gateway's business, or that of an implementation-specific policy object # this chart deliberately does not render. For THIS chart that includes the # upload cap: see paperless.files.maxUploadFileSize. annotations: {} # Gateway-API alternative to the Ingress above (Gitea #114). Off by default, so # an existing release is unaffected; exactly one of ingress.enabled and # httpRoute.enabled may be true - and ingress.enabled defaults to true, so it # has to be switched off explicitly. # # The chart renders ONLY an HTTPRoute with parentRefs - no Gateway, no # gatewayClassName, no Certificate and no implementation-specific policy # object. Class and certificate belong to the Gateway and therefore to the # platform, which is what keeps this chart independent of whether the cluster # runs nginx-gateway-fabric, Cilium, Envoy Gateway or Istio. # # WHAT THIS MODE COSTS HERE: paperless.files.maxUploadFileSize is not enforced, # so the web UI's upload ceiling is the Gateway's. The consume directory is # unaffected - it passes no proxy and has no limit at all. # # The Gateway-API CRDs must be installed in the cluster: the chart does not and # cannot detect them (.Capabilities.APIVersions is unreliable at template # time), so with httpRoute.enabled: true and no CRDs the INSTALL fails. httpRoute: enabled: false # The Gateway(s) this route attaches to - a platform object, usually in # another namespace, which must allow this namespace in its listener's # allowedRoutes. One entry per parent, the fields are the Gateway API's # ParentReference (name, and optionally namespace, sectionName, port, group, # kind), passed through unchanged: # # parentRefs: # - name: public # namespace: gateway # sectionName: https # # REQUIRED while httpRoute.enabled is true - the render aborts on an empty # list, because a route without a parent is created, is valid and routes # nothing, with no event to say so. parentRefs: [] # Empty means [ingress.domain], so the hostname has one source of truth. Set # explicitly only to publish under a different or an additional name - which # is how a migration from the Ingress runs: the HTTPRoute takes a second # hostname while the Ingress keeps ingress.domain. # # For THIS chart, mind that a second hostname 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). hostnames: [] paperless: # Replica count for this workload; 0 is allowed and scales it down. # The chart-global scaleDown flag takes precedence. replicas: 1 # Auxiliary busybox image, used by the /run-ownership init container and the # SMB consume-mover CronJob. Pinned independently of appVersion (which # tracks paperless-ngx); reviewed alongside every app-version update round. busybox: image: repository: busybox tag: "1.38.0" ocr: languages: "eng" # Paperless-ngx 3.x modes: auto (default, skips OCR when embedded text # exists), redo, force, off. The 2.x values "skip"/"skip_noarchive" were # removed upstream; "skip" maps to "auto". mode: "auto" # Archive (PDF/A) generation, decoupled from OCR in 3.0: auto (default, # skip archive for born-digital PDFs), always (2.x behavior), never. archiveFileGeneration: "auto" rotate_pages: "true" output_type: "pdfa" files: filenameFormat: "{{ created_year }}/{{ correspondent }}/{{ title }}" storage: 20Gi maxUploadFileSize: 10m # The barcode scanner backend is no longer configurable: paperless-ngx 3.0 # removed pyzbar and PAPERLESS_CONSUMER_BARCODE_SCANNER; zxing-cpp is the # only backend now. # # An empty or null "barcodes:" block in an override means "the defaults # below apply" — the chart merges the block onto these defaults instead of # letting a childless key erase them (issue #61; the fallback copy lives in # the paperless-ngx.barcodes helper and is kept in sync with this block). # To switch barcode handling off, set barcodes.enabled: false explicitly; # deleting the sub-keys does not disable it. barcodes: enabled: true barcodeString: "PATCHT" retainBarcodePage: false asn: enabled: true # Paperless-ngx 3.0 no longer rejects duplicate documents by default; set # to true to restore the 2.x behavior of deleting duplicates on consume. deleteDuplicates: false tikaGotenberg: enabled: false tikaEndpoint: null gotenbergEndpoint: null scripts: postConsumptionScripts: [] preConsumptionScripts: [] livenessProbe: httpGet: path: / port: 8000 periodSeconds: 30 failureThreshold: 3 successThreshold: 1 timeoutSeconds: 5 readinessProbe: httpGet: path: / port: 8000 periodSeconds: 10 failureThreshold: 3 successThreshold: 1 timeoutSeconds: 5 # The startup probe gates liveness/readiness. Paperless runs database # migrations on startup, and since 3.0 the first start after an upgrade # also rebuilds the search index from scratch (Whoosh -> Tantivy): # budget 10s * 60 = 600s. startupProbe: httpGet: path: / port: 8000 periodSeconds: 10 failureThreshold: 60 timeoutSeconds: 5 # Extra environment variables, appended to the ones the chart always sets. # Values are rendered through tpl; value, secretKeyRef and configMapKeyRef # forms are supported. paperless-ngx reads around 80 PAPERLESS_* variables and # this chart maps a handful of them explicitly - everything else goes here. # # env: # - name: PAPERLESS_LOGGING_DIR # value: /tmp/paperless-log # # ADDITIVE MEANS ADDITIVE, and the timing matters: an entry repeating a name # the chart already sets reaches the pod twice, and at INSTALL the later one - # yours - wins. On an EXISTING release the same entry is lost, because # Kubernetes merges env by name and the pair collapses to the chart's own # value, while helm upgrade still reports success. Read an override back off # the StatefulSet, never off the upgrade's exit code. # # VALUES MUST BE STRINGS (the schema enforces it) and they are quoted on the # way out since 13.0.0. Before that a value beginning with "*" aborted the # render and "true" or "0755" arrived as a bool or an integer, which the API # server rejects - see the comment at the render site in # templates/paperless-server.sfs.yaml. # # THE WORKED EXAMPLE, PAPERLESS_LOGGING_DIR, AND WHAT IT DOES NOT FIX # (jk-cluster-config #360): with the data directory on an RWX volume that # Longhorn exports over NFS, paperless' concurrent_log_handler cannot hold its # lock file there and fails with "RuntimeError: Cannot acquire lock after 20 # attempts" - measured at 45 % of the application log across all three # installations. Pointing PAPERLESS_LOGGING_DIR at a path under the /tmp # emptyDir this pod already mounts takes the log off the NFS share and the # noise stops. It does NOT fix the RWX data volume, which is the actual root # cause and a cluster-config decision (the PVC asks for ReadWriteOnce and was # provisioned ReadWriteMany; taking RWX back means recreating the volume). # A log under /tmp does not survive a pod restart - no loss here, paperless # also writes to stdout, which is what kubectl logs reads. env: [] resources: # Limits are optional: memory and ephemeral-storage limits default to # limitFactor x the request, an explicitly set limit always wins, and a # CPU limit is only set when configured explicitly under "limits". limitFactor: 3 requests: cpu: 0.2 memory: 1024Mi ephemeralStorage: 200Mi # Paperless-ngx supports rootless operation: the pod runs as uid/gid 1000 # (the image's default paperless user), fsGroup makes the PVCs writable and # /tmp + /run are emptyDirs so the root filesystem stays read-only. # Documented deviation: s6-overlay requires /run to be OWNED by the runtime # uid (group-write is not enough), so a minimal root init container chowns # the /run emptyDir to runAsUser/runAsGroup before the hardened main # container starts. securityContext: pod: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 fsGroupChangePolicy: Always container: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true capabilities: drop: - ALL smbConnection: enabled: false server: null # Cron schedule of the consume mover. Quote it or not as you like - the chart # quotes it on the way into the manifest (since 13.0.0, Gitea #11), which is # what a schedule beginning with "*" or "@" needs. Before that such a # schedule aborted the render with a YAML error pointing at the template. cronJob: "0 0,12 * * *" cron: # Run the mover on the node that carries the StatefulSet pod (Gitea #101). # # WHY IT DEFAULTS TO TRUE: this pod 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 - a mover pod that ran on # a different node leaves the kubelet of the StatefulSet's node retrying # every two minutes with "no Pending workload pods for volume ... to be # mounted" (291 events in 10 hours, in both releases with SMB on; the # release without the CronJob produced none). # # It is a switch and not a free-form affinity block on purpose: the default # has to name the StatefulSet pod's label, that label contains the RELEASE # NAME, and a values.yaml is never templated - so the useful default cannot # be expressed here at all (the same property that forces # valkey.secrets.authSecretRef to be typed out twice, further down). # # WHAT IT COSTS, AND IT IS A REAL COST: the affinity is required, so with # the StatefulSet pod absent the mover cannot be scheduled. The two ways # that happens are handled rather than left open - scaleDown: true suspends # this CronJob outright, and activeDeadlineSeconds below fails a job that # cannot be placed instead of letting Pending pods pile up. What stays: with # paperless.replicas: 0 and SMB on, the mover does not run and reports one # failed job per schedule. Set this to false to get the old, unpinned # behaviour back - including the mount errors. pinToServerNode: true # How late a missed run may still start. Also keeps the CronJob controller # from reaching "too many missed start times (> 100)", after which it stops # scheduling this job entirely and says so only in its own log - reachable # by leaving scaleDown: true in place for months. The cost of a window: a # run that could not start inside it is skipped, not queued. Must stay well # above 10s, below which the controller may never schedule at all. startingDeadlineSeconds: 600 # Hard stop for one mover run, counted from the job's start - so it also # bounds a pod that never gets scheduled (see pinToServerNode). Exceeding it # shows up as a job with reason DeadlineExceeded, which is the point: a # loud, self-clearing failure instead of a Pending pod that nobody sees. # Raise it if a run legitimately moves more than an hour's worth of files; # the mover copies across filesystems, so large scans are not instant. activeDeadlineSeconds: 3600 # Extra environment variables for the mover container, same form as # paperless.env above. Deliberately empty: the container is a busybox # running one mv, so this exists for the odd proxy or locale variable and # for the convention that every workload container offers the block - not # because the mover needs configuration. Note that the cron SCHEDULE's time # zone is the controller's and cannot be changed with a TZ entry here. env: [] # The consume mover only shuffles files between the SMB share (mounted # with 0777 modes) and the consume PVC, so it runs as the same non-root # uid/gid as paperless itself with a fully hardened context. securityContext: pod: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 fsGroupChangePolicy: Always container: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true capabilities: drop: - ALL resources: # Limits are optional: memory and ephemeral-storage limits default to # limitFactor x the request, an explicitly set limit always wins, and a # CPU limit is only set when configured explicitly under "limits". limitFactor: 3 requests: cpu: 10m memory: 16Mi ephemeralStorage: 10Mi oidc: enabled: false autoLoginWithProvider: false disableRegularLogin: false provider: id: null name: null clientId: null discoveryUrl: null # Optional: explicit token endpoint auth method. "none" is the only accepted # value, and the schema enforces that (Gitea #11). # # WHY IT IS RESTRICTED, since the previous comment here named # client_secret_basic as the example and that was actively misleading: this # chart registers a PUBLIC client and configures NO client secret - it # renders OAUTH_PKCE_ENABLED and nothing else, and secrets.oidcSecretRef was # removed in 13.0.0 because no secret can reach paperless through a json # environment variable anyway. A method that authenticates WITH a client # secret therefore cannot work here; asking for one produced a login that # died at the callback with invalid_client, while the two values read # together looked like a working secret setup. # # So: null omits the key and lets allauth decide, "none" states the public # client explicitly - which is what some providers need spelled out since # the allauth update in paperless-ngx 3.0, and the symptom when they do is # that same invalid_client at the callback. If a future paperless release # supports an auth method that needs no secret, widening the enum is a minor # bump; allowing one that needs a secret is not on the table. tokenAuthMethod: null # --------------------------------------------------------------------------- # The bundled Valkey - this repo's own valkey chart as an optional subchart # --------------------------------------------------------------------------- # The switch for the dependency declared in Chart.yaml. Off by default: an # installation that already has a Redis points redis-connection-url at it and # this renders nothing. # # WHY THE KEY IS HERE AND NOT "valkey.enabled": everything under "valkey:" is # handed to the subchart as its values and validated against ITS strict schema, # which has no "enabled" property - so "valkey.enabled" is rejected BY THE # SUBCHART. And because Helm skips a disabled subchart's schema validation, the # error would only ever have appeared for whoever first switched it on. # Chart.yaml carries the exact error text. # # DO NOT ERASE THIS BLOCK. Unlike every other block in this file (issue #99), # "valkeySubchart: null" is not "the default applies" - a null override DELETES # the key, the dependency's condition path then does not resolve, and Helm # treats an unresolvable condition as ENABLED. So erasing the switch turns the # subchart ON, with no password set and only the subchart's own error to show # for it. Measured for Gitea #78; values.schema.json rejects it before anything # renders. An empty block ("valkeySubchart: {}") is fine, because Helm merges # that with the default below. valkeySubchart: enabled: false # --------------------------------------------------------------------------- # Values passed THROUGH to the valkey subchart # --------------------------------------------------------------------------- # Read as the SUBCHART's own values, so the key names are its schema, not this # chart's. `valkey/values.yaml` in this repo is the reference. Nothing here has # any effect while valkeySubchart.enabled is false. # # What still applies in full: the subchart is invisible to everything # downstream that watches Helm release versions (Chart.yaml has the detail and # the PR-ordering warning - never change valkey/ and this chart in the same # PR), and this chart's scaleDown flag does NOT reach it. scaleDown: true # scales paperless to zero and leaves the Valkey running; that is the right way # round for a queue, because the backlog stays where it is. valkey: secrets: # REQUIRED once the subchart is on, and it has to be set BY HAND to the # SAME op:// path as secrets.redisSecretRef above - because the # "requirepass" line lives in that same item (see the three traps below). # # IT CANNOT DEFAULT TO IT, and that is a property of 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 }}" here does not resolve, it # lands verbatim in the subchart's itemPath and fails as a YAML parse error # pointing at the subchart's template, which names neither this value nor # the real cause. Measured; Gitea #78 has the output. So the path is typed # twice, and forgetting it is caught by a conditional rule in # values.schema.json - NOT by a fail() in a template, which cannot work # here: Helm renders a subchart's templates BEFORE the parent's, so the # subchart's own required() always wins that race and reports # "secrets.authSecretRef" instead of this value's real path. The schema runs # earlier than any rendering, which is why the rule lives there. # # MIND THE FIELD, it is not a password. The item must carry a field # "auth-conf" whose value is the COMPLETE configuration line # requirepass # The valkey chart mounts that field as a file and `include`s it, so the # password never reaches the process list, an environment variable, or # Helm's field of view. An init container refuses to start the pod unless # the line is really there, because the alternative failure is an open # server that looks perfectly healthy. A bare password does not work. # # OVERRIDING IT WITH A DIFFERENT ITEM IS ALLOWED and is not a mistake: it # is the way to feed the bundled Valkey from its own item. The cost is that # two items then have to be kept in step instead of one. authSecretRef: null # QUEUE, not cache - a decision, with the conflict it does not resolve. # # The valkey chart treats persistence and eviction as a PAIR and warns # against mixing them: QUEUE is persistence.enabled: true + "noeviction" # (its default), CACHE is persistence.enabled: false + "allkeys-lru". # stalwart picked CACHE. paperless-ngx is the other case - which happens to # be the subchart's own default, so the block below changes nothing. It is # written out anyway, for two reasons: the subchart's persistence block is # deliberately NOT null-safe (an erased one renders an invalid PVC), and the # decision belongs where it is argued rather than one chart away. The # eviction policy is NOT restated - valkey.maxmemoryPolicy stays at the # subchart's "noeviction", which is the queue half of the pair. storageClass # stays at the subchart default too (longhorn-encrypted-ns): queue payloads # are the same documents that are in the database, so encrypted at rest. # # Read off the paperless-ngx 3.2.1 source (src/paperless/settings/__init__.py # and settings/custom.py), not assumed: PAPERLESS_REDIS is ONE url, and # parse_redis_url returns it twice for a TCP url, so one instance carries # five consumers: # # 1. CELERY_BROKER_URL - the queue: consume, OCR, index # updates, classifier training, the # scheduled tasks # 2. CELERY_RESULT_BACKEND - also Redis in 3.x (it was the # database before), CELERY_RESULT_EXPIRES # 3600 # 3. CHANNEL_LAYERS - channels_redis.pubsub, expiry 15s; # pub/sub, not stored keys # 4. CACHES["default"] - Django cache, recomputable # 5. CACHES["read-cache"] - Cachalot DB read cache, TTL 3600, # off unless # PAPERLESS_DB_READ_CACHE_ENABLED # # WHY QUEUE: "allkeys-lru" would not be a trade here, it would be actively # wrong. LRU evicts across the WHOLE keyspace with no regard for key type, # and a backlog of waiting consume tasks is exactly what LRU 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 directory watcher has already fired. # And an evicted Celery result means a task that shows as "started" in the UI # forever. # # THE CONFLICT THAT STAYS, 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 - which hits # 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 and not a side effect, and the answer to it is to SIZE # MEMORY for the working set, never to switch the policy. What makes 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. persistence: enabled: true appendonly: true # Sized for a task backlog and its results, not for documents - the # documents are on paperless' own PVC. Grow it if a release regularly # queues thousands of tasks (a PVC can grow, it can never shrink). size: 1Gi # networkPolicy and the Valkey resources are left at the subchart's defaults # on purpose: its NetworkPolicy is on and allows the release's own namespace, # which with a subchart is where paperless runs. allowedNamespaces only needs # an entry when the two are in DIFFERENT namespaces, which a subchart rules # out. # --------------------------------------------------------------------------- # THE THREE TRAPS OF THE BUNDLED VALKEY - all three are operator work # --------------------------------------------------------------------------- # 1. THE PASSWORD IS IN THE ITEM TWICE: inside redis-connection-url and again # in the "requirepass" line of auth-conf. A rotation has to touch BOTH # fields; touching one leaves a paperless that cannot authenticate against # its own bundled 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. # # 2. WITH THE SUBCHART ON, redis-connection-url MUST POINT AT THE SUBCHART: # redis://:@-valkey-service:6379 # The name is determined and confirmed against the rendered output, but the # url lives in the vault, so this is a MANDATORY STEP of the installation # and not a hint. Left pointing at an external Redis, the bundled Valkey # runs along unused and nothing says so. NOTES.txt prints the exact name for # the release at hand. # # 3. A OnePasswordItem MATERIALIZES EVERY FIELD OF THE ITEM. With both CRs # pointing at the same item, the Valkey Secret therefore also carries # redis-connection-url and key-prefix, and this chart's redis Secret also # carries auth-conf. Untidy, harmless, and written down here so nobody reads # it as a leak or a bug and "tidies it up": the keys are unused, and no # container mounts a field it does not reference. # # And one rule that is about this repository rather than about an installation: # changes to valkey/ must not share a pull request with changes to this chart. # Chart.yaml says why; README and the wiki's valkey page carry the long form.