# 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 oidcSecretRef: null 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. 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 cronJob: "0 0,12 * * *" cron: # 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 (e.g. client_secret_basic). # Some providers need this since the allauth update in paperless-ngx 3.0 # (login fails at the callback with invalid_client otherwise). 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.