Files
jklein 530227405c fix(paperless-ngx): egress names the Tika/Gotenberg POD ports, not the Service port
The rule allowed outbound 80, the port the apache-tika and gotenberg Services
listen on, and justified it in a comment: an egress rule names the port on the
destination as the client dials it, and the client dials the Service. That is
wrong. kube-proxy does a ClusterIP Service's destination NAT BEFORE the egress
policy is evaluated at the pod's network interface, so the policy sees
podIP:targetPort and never clusterIP:port - and those two charts forward 80 to
9998 and 3000. Deployed as 14.0.0+up3.3.1 it cut Paperless off from both
services on 2026-10-15: mail import, EML, HTML and Office documents stopped,
plain PDFs and images kept working, and the error was httpx.ConnectTimeout with
no mention of a NetworkPolicy.

The rule now names 9998, 3000 and 80. The 80 stays because it costs nothing and
covers a foreign Tika that really does listen on 80. The refuted sentence is
quoted rather than deleted: it reads perfectly, which is how it survived review,
and the timeout-not-refused symptom is written down with it.

No policy switch of its own: the rule stays gated on
paperless.files.tikaGotenberg.enabled, because a second value that can drift
from the feature re-arms this very outage.

New networkPolicy.egress.mailImport opens IMAPS 993, off by default - paperless'
mail accounts are rows in its database, so the chart cannot derive the rule.
Plain IMAP (143) and POP3 stay in extraPorts, and 993 is noted as a real
destination port, because a mail server outside the cluster has no DNAT in the
path.

Minor: one additive value, and a fix that widens a rule. Nothing breaks, no
value becomes required, appVersion unchanged.

Part of #136
2026-10-15 17:39:24 +00:00

674 lines
35 KiB
YAML

# 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 <password>
# 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://:<password>@<release>-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.
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# WHAT THIS POLICY ALLOWS: inbound 8000 only, and outbound DNS plus TCP 5432
# (Postgres), 6379 (Redis/Valkey), 443, and - only while
# paperless.files.tikaGotenberg.enabled is true - 9998, 3000 and 80 for Tika
# and Gotenberg.
#
# WHY THAT RULE NAMES 9998/3000 AND NOT JUST THE SERVICES' 80 (Gitea #136, an
# outage on 2026-10-15). The note that used to stand here said:
#
# "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 ports."
#
# That is wrong, and it is kept as a quotation because it sounds right - which
# is why it was never checked. kube-proxy does the ClusterIP Service's
# destination NAT BEFORE the egress policy is evaluated at the pod's network
# interface, so the policy sees podIP:targetPort, never clusterIP:port. An
# egress rule therefore names the POD port: 9998 for apache-tika, 3000 for
# gotenberg, both read from those charts' containerPorts. The 80 stays because
# it costs nothing and covers a foreign Tika that really does listen on 80.
#
# AND THE FAILURE MODE IS A TIMEOUT, NOT "connection refused": a missing
# service refuses, a dropped packet runs into the timeout. 5432, 6379 and 443
# were unaffected only because their Service port and pod port are the same
# number.
#
# THREE THINGS IT DOES NOT COVER:
# - The VALKEY SUBCHART'S POD. It carries its own labels, so this policy
# does not select it - and it does not need to: the valkey chart ships a
# NetworkPolicy of its own, and a tighter one.
# - The CONSUME CRONJOB'S PODS. Their pod template carries NO LABELS, so no
# podSelector can match them and nothing here restricts them. Tolerable
# only because the job moves files between mounted volumes and the SMB
# share arrives as a CSI volume mounted by the NODE - the pod opens no
# connection of its own. If that changes, the CronJob needs labels first.
# - MAIL FETCHING, unless egress.mailImport is switched on. Paperless' mail
# accounts are rows in its database, configured in the web UI, so this
# chart cannot see them and cannot derive the rule. Left off on a release
# that does fetch mail, a mail rule silently stops importing.
#
# And the usual two: 5432 and 6379 are the STANDARD ports rather than the
# configured ones, and a policy is only a boundary where the CNI enforces
# one.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach port 8000, which is the port all three probes address.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot 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.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
# The ingress controller allowed to reach the routed port once probeSources
# restricts it. Rendered only while ingress.enabled or httpRoute.enabled is true, and only in
# the restricted branch - with the default (open) rule the controller reaches
# the port anyway. The two keys become ONE "from" entry and are therefore an
# AND: pods carrying podLabels IN namespace. That is the point of having them
# at all - the controller's namespace in allowedNamespaces would open the port
# to every pod in kube-system.
#
# MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone,
# the only IngressClass is "traefik" and it is the default, and the controller
# is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the
# Gateway API here, so this one source covers the Ingress and the HTTPRoute
# path alike. A policy written against the wrong controller is accepted, looks
# strict, and silently blocks all web traffic - and ingress.className says
# nothing about it, because a class names an implementation, not the pods the
# traffic comes from.
ingressController:
namespace: kube-system
podLabels:
app.kubernetes.io/name: rke2-traefik
egress:
# Outbound IMAPS (993) for paperless' mail import. OFF, and it has to be a
# value rather than something the chart derives: the mail accounts are ROWS
# IN THE DATABASE, entered in the web UI, so nothing in these values says
# whether a release fetches mail. Switching this on is the operator saying
# that it does.
#
# 993 ONLY, deliberately. Plain IMAP (143) and POP3 (110, 995) are not
# covered - a value named mailImport that opened four ports would be wider
# than its name - and belong in extraPorts below.
#
# NOTE THE DIFFERENCE FROM THE TIKA RULE: 993 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. What decides whether an egress rule
# needs the Service port or the pod port is that NAT, and nothing else.
mailImport: false
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# PLAIN IMAP (143) and POP3 (110, 995) go here - IMAPS 993 has its own
# switch above. Also a Postgres or Redis on a non-standard port, and a Tika
# or Gotenberg that listens on neither its chart's container port nor 80.
#
# These are POD ports for anything inside the cluster, for the reason given
# at the top of this block: a ClusterIP Service's port is translated away
# before the policy sees the packet. For a destination outside the cluster
# there is no translation and the port is simply the one being dialled.
extraPorts: []