paperless-ngx: the optional valkey subchart, and two findings it produced

paperless-ngx (Gitea #78) takes our own valkey chart as an optional subchart,
off by default. Its page gets the two new value blocks, the five Redis
consumers that argue the queue-vs-cache choice, the three operator traps and
the CI section, which now describes the first real installation of the
valkey-subchart path anywhere here.

Two findings are general enough to leave the chart page:

- A rule about the relationship between a parent's and a subchart's values
  cannot live in a template. Helm renders a subchart's templates first, so the
  subchart's own required() always wins and reports the path as the subchart
  sees it. Schema validation runs before any rendering, so that is where such a
  rule goes. The first draft was a fail() in the parent that could never fire.
- Erasing a dependency switch turns the subchart ON: a null override deletes
  the key, an unresolvable condition counts as enabled. The one block in a
  chart that must not be null-safe, and the opposite of the rule for every
  other block.

Both in Chart-Style-Guide section 14 and Lessons-Learned; valkey's page names
its second consumer and the queue/cache pair of worked examples; Charts gets
the row.
jklein committed 2026-10-02 21:29:18 +00:00
1 parent 1eb6831a58
commit 43aef80b2e
5 files changed
+144 -15

No files matched your search

+8 -1
@@ -192,7 +192,9 @@ Two mechanics come with it: Go's `not` treats **every non-empty string as true**
## 14. Chart dependencies
The former "no dependencies" rule is gone, replaced by a bar rather than a ban. Two charts use one. `ytdlp-web-player` needed **upstream's** oauth2-proxy in the data path in front of an application that ships no authentication at all, and proxy and app are only useful wired to each other. `stalwart` takes **one of our own published charts**, `valkey`, because Stalwart's cluster coordinator rides on the in-memory store connection and a multi-node install does not work without one.
The former "no dependencies" rule is gone, replaced by a bar rather than a ban. Three charts use one. `ytdlp-web-player` needed **upstream's** oauth2-proxy in the data path in front of an application that ships no authentication at all, and proxy and app are only useful wired to each other. `stalwart` takes **one of our own published charts**, `valkey`, because Stalwart's cluster coordinator rides on the in-memory store connection and a multi-node install does not work without one. `paperless-ngx` takes the same one, because `PAPERLESS_REDIS` is the Celery broker and the application cannot run without a Redis at all — before the subchart every installation needed a second Helm release whose address lived in a 1Password item and in neither chart.
**The same subchart, opposite configuration — which is what makes the two the useful comparison.** `stalwart` runs `valkey` as a **cache** (`persistence.enabled: false` + `allkeys-lru`), `paperless-ngx` as a **queue** (PVC + `noeviction`). The valkey chart treats those two values as a **pair** and warns against mixing them, so picking a half is a decision each parent argues in its own `values.yaml` rather than inherits. The general rule the two cases produce: `allkeys-lru` is wrong wherever the keyspace contains *work* rather than only derived data, because LRU evicts typelessly and queued work is exactly what it reads as cold.
**When a dependency earns its place — all three, not two of three:**
@@ -219,4 +221,9 @@ Two mechanical facts about a self-hosted dependency, measured for `stalwart` rat
And the one that bites hardest: **everything under the subchart's values key is handed to the subchart and validated against *its* schema, so the `condition:` key must not live there.** Our charts ship `additionalProperties: false`, so the conventional `valkey.enabled` is rejected *by the subchart* — `at '': additional properties 'enabled' not allowed`. Helm skips a **disabled** subchart's schema validation, so the default path renders clean and the error appears only for whoever first switches it on. `stalwart` therefore gates on `valkeySubchart.enabled`, a key of the parent's own, which is also the honest shape: `enabled` was never a value of the subchart. It only looked like one because `oauth2-proxy`'s upstream schema is permissive.
**Two more, both measured for `paperless-ngx` (Gitea #78) and both general:**
- **A cross-chart values rule belongs in `values.schema.json`, not in a `fail()`.** Helm renders a **subchart's** templates *before* the parent's, so a guard in the parent always loses the race to the subchart's own `required()` — and that message names the value by its path *inside* the subchart (`secrets.authSecretRef`, not `valkey.secrets.authSecretRef`) and cannot know what the parent knows about it. Schema validation runs **earlier than any rendering**, so an `if`/`then` in the parent's schema is the only form that both fires and names the real path. A parent-side `fail()` for this is not merely redundant; it is a check that **cannot** fire, which is the failure mode [§12](#12-values-that-must-not-lie) exists to prevent.
- **An erased `condition:` key switches a subchart ON.** Setting the switch to `null` **deletes** the key from the coalesced values, Helm then cannot resolve the `condition:` path, and an unresolvable condition counts as *enabled*. So the one block in a chart that must **not** be null-safe is the dependency switch — the opposite of [§12](#12-values-that-must-not-lie)'s rule for every other block, and in the worst direction, since the subchart comes up unconfigured. Require the key in the schema. An empty block (`{}`) is harmless: Helm merges it with the chart default.
`template-chart` remains dependency-free: the scaffold should not carry a `dependencies:` block a new chart has to remember to delete.
+1 -1
@@ -22,7 +22,7 @@ grep -E '^(version|appVersion):' <chart>/Chart.yaml
| [minecraft-router](minecraft-router) | itzg/mc-router | Deployment + ConfigMap + NodePort | no (TCP) | `mappings` required; the chart behind the `checksum/config` outage |
| [minecraft-server](minecraft-server) | itzg/minecraft-server | StatefulSet + PVC | no | only the newest Java line is maintained; `image.tagOverride` picks an older JVM |
| [outline](outline) | Outline | StatefulSet + upload PVC | yes | external Postgres and Redis; the reference page for this wiki |
| [paperless-ngx](paperless-ngx) | Paperless-ngx | Deployment + consume CronJob | yes | largest chart; SMB connector; the one documented probes exception |
| [paperless-ngx](paperless-ngx) | Paperless-ngx | Deployment + consume CronJob | yes | largest chart; SMB connector; the one documented probes exception; optional [valkey](valkey) subchart, configured as a **queue** |
| [patchmon](patchmon) | PatchMon | one Deployment, optional guacd sidecar | yes | probes must send a `Host` header or the server answers 403 |
| [rallly](rallly) | Rallly | Deployment | yes | DB, OIDC and SMTP secrets |
| [rss-bridge](rss-bridge) | RSS-Bridge | Deployment + ConfigMap | yes, **list of hosts** | documented ingress exception: each host may keep its own TLS secret |
+20
@@ -325,6 +325,24 @@ What earns this an entry is **when** it appears: Helm skips schema validation fo
The second was load-bearing for the decision, not a nice-to-have: without it the published-version form would have had **no update path at all**, and the comment naming Renovate as the safety net would have been false. "A capability list is about the product; only a run is about the installation" applies to update automation too.
## The guard that could not fire, and the switch that defaulted to ON
The second chart to take [valkey](valkey) as a subchart — [paperless-ngx](paperless-ngx), Gitea #78 — produced three findings, and all three were found by *trying* the thing rather than by reading about it.
**A parent cannot default a subchart's value, and the attempt fails in the most misleading way available.** The plan said `valkey.secrets.authSecretRef` should default to `secrets.redisSecretRef`, because the `auth-conf` field lives in that same 1Password item. That is not implementable: a `values.yaml` is **never templated**, and values are merged statically before any template runs, so there is no mechanism — `import-values` runs child→parent, and the subchart does not read `global`. What makes it an entry rather than a footnote is the failure mode. The expression lands **verbatim** in the subchart's `itemPath`, where YAML reads `{{` as a flow mapping:
```
Error: YAML parse error on paperless-ngx/charts/valkey/templates/valkey.secret.yaml:
error converting YAML to JSON: yaml: invalid map key:
map[interface {}]interface {}{".Values.secrets.redisSecretRef":interface {}(nil)}
```
A parse error, in somebody else's template, naming neither the value that caused it nor the file it was written in. The plan was wrong and was corrected on the issue before anything was built; the operator now types the path twice.
**A `fail()` in the parent could not have reported it, because it never runs.** The first draft put a guard in the parent's `_helpers.tpl` and called it from a rendered template — the right shape, per the entry above about validation that is skipped. It still fired never: **Helm renders a subchart's templates before the parent's**, so the subchart's own `required()` always wins, and its message names `secrets.authSecretRef` — the path inside the subchart, which is not the path anyone has to edit. The rule moved to the parent's `values.schema.json` as an `if`/`then`, because **schema validation runs before any rendering at all**. The general form: a rule about the relationship between two charts' values has exactly one place it can live, and it is not a template.
**And the one nobody was looking for: erasing the dependency switch turns the subchart ON.** `valkeySubchart: null` does not mean "the default applies". A null override **deletes** the key, Helm then cannot resolve the `condition:` path, and an **unresolvable condition counts as enabled** — so the block that reads like "off" produces a Valkey with no password configured, reported only by the subchart's own error. Everywhere else in these charts an erased block falls back to the chart defaults ([§12](Chart-Style-Guide#12-values-that-must-not-lie), issue #99); the dependency switch is the one block where that is backwards, so the schema now **requires** it. An empty `{}` is fine, because Helm merges that with the default — which is precisely why the dangerous case is easy to miss: the two spellings look equivalent and behave oppositely.
## The recurring shape
- **Green is a claim about the assertions that ran, not about the system.**
@@ -344,4 +362,6 @@ The second was load-bearing for the decision, not a nice-to-have: without it the
- **A convention is not a rule, and the difference shows up in somebody else's validator.** `condition: <name>.enabled` is fine until the subchart has a strict schema; the error then belongs to the subchart and appears only once the feature is switched on.
- **Validation that is skipped in the default path is not validation.** A check that only runs for the non-default branch passes CI forever and fails for the first person who deviates.
- **A reference to something that moved is false, not stale.** It still answers the question, which is why nobody re-asks it — so a file leaving the repository is a grep across the whole tree, comments included, not only the lines a tool can resolve.
- **A rule about two components' configuration cannot live in either one's templates.** Whatever renders first reports the error, and it reports it in its own vocabulary — so the check belongs where validation happens before rendering does.
- **"Erased" and "empty" are not the same value, and they can behave oppositely.** A null override removes a key; an empty object is merged with the default. Wherever a missing key is read as *enabled*, erasing a switch turns a feature on.
- **An answer that arrived by accident is worth making repeatable.** The all-charts install-test exists because one unplanned run produced a result nobody could have asked for; the fix is a way to ask the question on purpose, not a note to remember the accident.
+111 -11
@@ -1,22 +1,24 @@
# paperless-ngx
Deploys [Paperless-ngx](https://docs.paperless-ngx.com/), 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 and Redis are **not** part of this chart.
Deploys [Paperless-ngx](https://docs.paperless-ngx.com/), 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](valkey) chart along as an optional subchart**, off by default — see [the bundled Valkey](#the-bundled-valkey-one-step-of-it-is-in-the-vault).
| | |
|---|---|
| Image | `ghcr.io/paperless-ngx/paperless-ngx` |
| Versions | not listed on purpose — read the current pair off `paperless-ngx/Chart.yaml`, see [Chart index](Charts) |
| Workloads | StatefulSet (1 replica) with a root init container; optional consume CronJob |
| Storage | main PVC (RWO, `paperless.files.storage`) + consume PVC (RWX, **hardcoded 1Gi**); both on the hardcoded storage class `longhorn` |
| 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 |
| Ingress | yes, **always rendered** — this chart has no `ingress.enabled` |
| External dependencies | Postgres, Redis; optionally Tika + Gotenberg, an OIDC provider, the `smb.csi.k8s.io` driver |
| Secrets | 3 × `OnePasswordItem`, a 4th with SMB enabled |
| Install-test | `ci/paperless-ngx/` (fixtures + test values, `INSTALL_TIMEOUT=600`) |
| 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](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](#what-the-1password-items-must-contain).
- **Postgres and Redis reachable before the first start.** They fail in opposite ways and only one of them is loud — see [Redis fails quietly, Postgres does not](#redis-fails-quietly-postgres-does-not).
- **Postgres reachable before the first start**, and **Redis too unless you let the chart bring it**: either point `redis-connection-url` at an existing instance or set `valkeySubchart.enabled: true`, which deploys this repo's own [valkey](valkey) chart alongside. They fail in opposite ways and only one of them is loud — see [Redis fails quietly, Postgres does not](#redis-fails-quietly-postgres-does-not).
- **With `valkeySubchart.enabled`:** a third field `auth-conf` in the Redis 1Password item, and the connection url repointed at the subchart's Service. Both are steps, not hints — see [the bundled Valkey](#the-bundled-valkey-one-step-of-it-is-in-the-vault).
- **A storage class named `longhorn`** that can serve **ReadWriteMany** for the consume claim. Both PVCs hardcode it; there is no value to change it.
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
- **With `smbConnection.enabled`:** the `smb.csi.k8s.io` CSI driver, which this chart does not install.
@@ -57,7 +59,8 @@ The chart renders `OnePasswordItem` CRs; the operator turns each into a Kubernet
| 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) |
| `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. |
@@ -159,13 +162,102 @@ TLS is stored in `tls-<release>-paperless-ngx-ingress`.
| `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 (e.g. `client_secret_basic`). Some providers need it since the allauth update in 3.0; the symptom without it is a login dying at the callback with `invalid_client`. |
### `valkeySubchart` and `valkey` (subchart)
The subchart is **this repo's own [valkey](valkey) chart**, resolved from the published index — not upstream's. So [§14](Chart-Style-Guide#14-chart-dependencies) 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](Chart-Style-Guide#12-values-that-must-not-lie), 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](stalwart) (Gitea #77).
**It is configured as a QUEUE, which is the opposite of what [stalwart](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.
## Operating notes
### 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.
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:
```bash
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 `include`s 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](valkey) page and in [§14](Chart-Style-Guide#14-chart-dependencies).
### What a green pod does not prove
@@ -276,12 +368,20 @@ Nothing about OIDC is exercised before someone actually signs in, so a wrong dis
## 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 and Redis, 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](CI-CD).
`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](CI-CD).
**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](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.
Both fixture Secrets carry **all three fields**, because in a real cluster both `OnePasswordItem`s 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](Chart-Style-Guide) — the rules this chart implements, and the two exceptions it holds
- [valkey](valkey) — the chart this one takes as an optional subchart, and the one to install separately when it should outlive this release
- [stalwart](stalwart) — the same subchart configured as a **cache** instead of a queue; the two pages together are the worked example of that pair
- [Versioning](Versioning) — what a bump of `12.1.1+up3.1.0` means
- [Renovate](Renovate) — how `appVersion` and the busybox image are updated
- [Renovate](Renovate) — how `appVersion`, the busybox image and the pinned subchart version are updated
- [Lessons learned](Lessons-Learned)
- [Chart index](Charts)
+4 -2
@@ -245,7 +245,7 @@ The `-alpine` tag was chosen over `-alpine3.24` precisely because both move equa
## This chart is also a subchart — do not change it together with its consumers
Since Gitea #65 **[stalwart](stalwart) takes this chart as a dependency**, resolved from the published index. That makes one rule binding on every change to `valkey/`:
Since Gitea #65 **[stalwart](stalwart) takes this chart as a dependency**, and since Gitea #78 **[paperless-ngx](paperless-ngx) does too** — both resolved from the published index. That makes one rule binding on every change to `valkey/`:
> **Never change `valkey/` and a chart that depends on it in the same pull request.**
@@ -254,7 +254,9 @@ The dependency resolves from the **published index**, not from the working tree,
Two further consequences of being consumed as a subchart, worth knowing before changing anything here:
- **A change here does not reach a consumer's cluster when this chart publishes.** It arrives when the *consumer* is republished with a raised `version:`. Renovate's `helmv3` manager opens that PR — verified against this self-hosted index — but until it merges, consumers keep shipping the old copy inside their own `.tgz`.
- **`additionalProperties: false` in this chart's `values.schema.json` is load-bearing for consumers, in a way that is easy to trip over.** Everything a parent puts under its `valkey:` key is handed here and validated against this schema, so a parent that gated the dependency on the conventional `valkey.enabled` is rejected with `at '': additional properties 'enabled' not allowed`. Helm skips a *disabled* subchart's validation, so the error surfaces only for whoever first switches it on. `stalwart` therefore gates on a key of its own, `valkeySubchart.enabled`. **Do not "fix" this by adding an `enabled` property here** — this chart does not read one, and a values key that does nothing is worse than its absence.
- **`additionalProperties: false` in this chart's `values.schema.json` is load-bearing for consumers, in a way that is easy to trip over.** Everything a parent puts under its `valkey:` key is handed here and validated against this schema, so a parent that gated the dependency on the conventional `valkey.enabled` is rejected with `at '': additional properties 'enabled' not allowed`. Helm skips a *disabled* subchart's validation, so the error surfaces only for whoever first switches it on. Both consumers therefore gate on a key of their own, `valkeySubchart.enabled`. **Do not "fix" this by adding an `enabled` property here** — this chart does not read one, and a values key that does nothing is worse than its absence.
- **`secrets.authSecretRef` cannot be defaulted by a parent, so expect consumers to make the operator type a path twice.** A `values.yaml` is never templated and a parent cannot compute a subchart's value; a template expression put there lands verbatim in this chart's `itemPath` and dies as a YAML parse error naming *this* template. Measured for Gitea #78, where `paperless-ngx` wanted exactly that because its `auth-conf` field sits in the same item as its Redis url. The fix that would have made it possible — `tpl` on `itemPath`, or a `global` fallback — was **rejected**: it would let arbitrary templating into a secret reference and tie this chart's independence to its parents. A consumer catches the omission in **its own `values.schema.json`** instead; it cannot catch it in a template, because Helm renders a subchart's templates *before* the parent's and this chart's `required()` always wins that race.
- **The two configuration shapes now have a worked example each.** [stalwart](stalwart) runs this chart as a **cache** (`persistence.enabled: false` + `allkeys-lru`), [paperless-ngx](paperless-ngx) as a **queue** (PVC + `noeviction`, the defaults here). Reading the two pages side by side is the fastest way to see what the pair at the top of `values.yaml` actually decides.
## Upgrades and backup