5
valkey
Justin Klein edited this page 2026-10-14 14:57:00 +00:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

valkey

Deploys one Valkey instance for one application — a cache or a queue backend — as a single-replica StatefulSet. It is the replacement for the shared cluster-redis, whose separation rested on a single ACL file in a single 1Password field: every change there touched six applications at once, needed a restart to take effect, and produced a month of Paperless standstill when one application's keys were not covered by the pattern (cluster-config#102). With one instance per application the ACL question disappears rather than being solved — each instance has exactly one user, and that user may do ~*.

Image docker.io/valkey/valkey (the official image, not bitnami/valkey — Bitnami publishes only latest since August 2025)
Versions not listed on purpose — read the current pair off valkey/Chart.yaml, see Chart index
Workloads StatefulSet (1 replica) + one validating init container
Storage one RWO PVC when persistence.enabled (default), otherwise an emptyDir
Ingress no — and no value to add one
External dependencies none
Secrets 1 × OnePasswordItem
Network ClusterIP Service on 6379, plus a NetworkPolicy on by default
Install-test ci/valkey/ (fixtures + test values, the persistent variant)

Prerequisites

  • 1Password operator in the cluster, plus one 1Password item carrying exactly one field — see the field below. The field holds a finished configuration line, not a password, and the exact spelling matters.
  • A storage class matching persistence.storageClass (default longhorn-encrypted-ns) when persistence is on.
  • Nothing else. Valkey depends on no database, no other service and no network egress.

Installation

The registry URL and its credentials live in 1Password (item jlab-registry-access: username, password, charts-url) and never in a repository or a shell history file.

helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
helm repo update

helm install sure-valkey jlab/valkey \
  --namespace sure --create-namespace \
  -f my-values.yaml

Minimal my-values.yaml — one required value, because everything else has a default that is safe for a queue:

secrets:
  authSecretRef: op://vault/sure-valkey

For a pure cache, two values instead of one (they belong together, see Persistence and eviction are a pair):

secrets:
  authSecretRef: op://vault/searxng-valkey

persistence:
  enabled: false

valkey:
  maxmemoryPolicy: allkeys-lru

helm install prints the chart's NOTES.txt, which names the shape the release ended up in (queue / cache / the mistaken pair), the computed maxmemory, and the connection string to put in the consuming application's vault item. Read it — it is the per-release version of this page.

Naming

One instance per application, so the release name says which application it belongs to: sure-valkey, outline-valkey, oneuptime-valkey. Objects are then sure-valkey-sfs, sure-valkey-service and so on. The instance normally lives in the consumer's namespace, which is also what makes the default NetworkPolicy sufficient without a single extra value.

What the 1Password item must contain

The chart renders one OnePasswordItem; the operator turns it into a Kubernetes Secret whose keys are the field names of the 1Password item.

Values key Kubernetes Secret Field Content
secrets.authSecretRef <release>-valkey-secret auth-conf a complete Valkey configuration line: requirepass <the password> — not the bare password

Two consequences, and both are the point of the design:

  • The password never appears anywhere but the vault and the pod's own tmpfs. The file is mounted and included by the configuration, so there is no --requirepass in the process list, no environment variable, and nothing Helm renders. The same applies to the probes, which read the password out of that same file.

  • A typo in the field would be an open server. requirepas, a stray quote, or an empty field means Valkey starts without a password — no render error, no crash, a green pod, and (because the official image compiles protected-mode off) reachable from the whole pod network. An init container therefore checks the mounted file before the server starts and refuses to let the pod run without a requirepass <value> line. If the pod hangs in Init:Error or Init:CrashLoopBackOff, that is the first thing to read:

    kubectl -n <namespace> logs <release>-valkey-sfs-0 -c <release>-valkey-check-auth
    

    It names the field and never prints its contents.

A wrongly named field fails differently and earlier: the volume cannot be projected and the pod stays in ContainerCreating with couldn't find key auth-conf in its events.

The connection string for the consuming application belongs in that application's item, not here:

redis://:<password>@<release>-valkey-service.<namespace>.svc.cluster.local:6379/0

redis:// is correct — Valkey speaks the same protocol and every client library still calls the scheme that.

Configurable values

Global

Key Type Default Description
scaleDown bool false Renders the StatefulSet with replicas: 0 without uninstalling. Takes precedence over valkey.replicas. PVC, Secret, Service and NetworkPolicy stay.

secrets

Key Type Default Description
secrets.authSecretRef string null Required. op:// path of the item carrying the auth-conf field.

valkey

Key Type Default Description
valkey.replicas int 1 0 or 1 — the schema rejects anything higher. See One instance, on purpose.
valkey.maxmemoryFactor number 0.75 Fraction of the computed memory limit that Valkey may fill with data. Ignored when valkey.maxmemory is set. See maxmemory is derived, not configured.
valkey.maxmemory string null Explicit maxmemory as a Kubernetes quantity (512Mi). A value above the computed memory limit aborts the render, and so does 0.
valkey.maxmemoryPolicy string noeviction Valkey's maxmemory-policy, as an enum of the eight real policies. noeviction for queues, allkeys-lru for caches.
valkey.extraConfig string "" Extra configuration lines, appended verbatim (rendered through tpl). Cannot switch the password off — the auth include is rendered after it and the last directive wins. It can override maxmemory, which then escapes the limit check; use valkey.maxmemory.
valkey.securityContext.pod object non-root 999, runAsGroup/fsGroup 1000, fsGroupChangePolicy: Always, seccompProfile: RuntimeDefault Passed verbatim into the pod spec. The uid is the image's own valkey user — see Why uid 999 and gid 1000. The seccomp profile sits here so it also covers the init container.
valkey.securityContext.container object readOnlyRootFilesystem: true, all caps dropped, no privilege escalation, non-root Applied to both containers. /data is the only writable path and needs no emptyDir beyond it.
valkey.livenessProbe object exec /bin/sh /etc/valkey/ping.sh, period 10s, timeout 5s, 3 failures An authenticated valkey-cli ping. See What a green pod proves.
valkey.readinessProbe object same command, period 10s, timeout 5s, 3 failures
valkey.startupProbe object same command, period 5s, 30 failures (= 150s) Gates liveness and readiness. The budget covers replaying a large append-only file.
valkey.env list [] Extra environment variables. Rendered through tpl; value, secretKeyRef and configMapKeyRef forms. Additive, not overriding — see Environment variables. Note that the password deliberately does not travel this way: the image entrypoint appends $VALKEY_EXTRA_FLAGS to the server command line, which would put it in the process list.
valkey.resources.limitFactor number 3 Memory and ephemeral-storage limits are computed as factor × request. This is also what sizes maxmemory.
valkey.resources.requests object cpu: 0.1, memory: 256Mi, ephemeral-storage: 64Mi
valkey.resources.limits object unset An explicit limit always wins. A CPU limit is only rendered when set here.

persistence

Key Type Default Description
persistence.enabled bool true true = RWO PVC, false = emptyDir and both persistence mechanisms switched off (appendonly no, save ""), so nothing is written to ephemeral storage.
persistence.appendonly bool true AOF instead of RDB-only. For a queue the question is not "is there a backup" but "how much is lost when the pod dies": a snapshot loses every job since the last one, silently. Has no effect while persistence.enabled is false.
persistence.size string 1Gi Small on purpose: on encrypted Longhorn volumes space is never returned to the pool once written, and a PVC can grow but never shrink.
persistence.storageClass string longhorn-encrypted-ns null uses the cluster default — which is not encrypted, so leaving the default is a decision about data at rest. A cache regularly holds the same payloads as the application's database.

networkPolicy

Key Type Default Description
networkPolicy.enabled bool true Ingress to 6379 from the release's own namespace. Egress unrestricted (this instance opens no outbound connection).
networkPolicy.allowedNamespaces list [] Additional namespaces, matched on the kubernetes.io/metadata.name label Kubernetes sets itself — so no labelling work, but also no typo protection: a name that does not exist matches nothing and is silently ineffective.

There is no ingress section. A Valkey whose only authentication is one password does not belong behind a public hostname, so the values that would allow it are absent rather than switched off.

There is also no probeSources and no ingressController, and that is what makes this the tightest policy in the repository rather than an incomplete one. Since Gitea #5 every chart here ships a policy, and almost all of them have to leave their probed port open: the kubelet probes from the IP of the worker node running the pod, an address no chart can know. This chart does not, because all three of its probes are exec probes — they run inside the container through the kubelet's runtime and put no packet on the network at all. So there is no probe traffic to accommodate, 6379 can be closed to everything but the namespaces above, and no escape-hatch value is needed. minecraft-server is tight for the same reason; every HTTP-probed web app in the repo is not.

policyTypes naming Ingress only is the other half, and the convention now cites it in both directions: an egress rule here would constrain nothing while making the object look stricter than it is, and homeassistant and satisfactory invoke the same rule for outbound traffic nobody can enumerate. See Chart-Style-Guide for the four port classes this chart is the reference for.

Operating notes

Persistence and eviction are a pair, not two switches

The only two combinations that make sense:

Shape persistence.enabled valkey.maxmemoryPolicy For
Queue (chart default) true noeviction Sidekiq, Celery — entries are work that must not vanish
Cache false allkeys-lru recomputable data only

Mixing them is what the defaults exist to prevent:

  • A queue with allkeys-lru throws jobs away under memory pressure while every application stays green. That was one of the hypotheses in cluster-config#102 and it is far cheaper to prevent here than to diagnose there.
  • No persistence with noeviction is almost never intended: the data is gone at every restart, so nothing is being protected — but a full instance starts refusing writes instead of dropping cold keys. The rendered NOTES.txt prints a warning for exactly this pair.

maxmemory is derived, not configured

maxmemory comes from the container's computed memory limit (limitFactor × the memory request, or an explicit limits.memory), multiplied by maxmemoryFactor (0.75). With the chart defaults: request 256Mi → limit 768Mi → maxmemory 603979776 (576 MiB).

Two properties worth knowing before changing it:

  • The number is read back out of the rendered resources block, not calculated a second time, so it cannot drift from what the container actually gets.
  • A configured valkey.maxmemory above that limit aborts the render, with both byte counts in the message. It is not clamped and not warned about: above the limit, the kubelet's OOM kill decides what the eviction policy was configured to decide, and that arrives as a restarted pod with no explanation. valkey.maxmemory: 0 aborts too — in Valkey 0 means unlimited, i.e. the exact opposite of what the value is for.

The quarter that is not handed to maxmemory is not slack: client output buffers, the AOF buffer, and above all the copy-on-write pages of the fork() that writes a snapshot or rewrites the AOF all live outside maxmemory.

Why uid 999 and gid 1000

Not the repo's usual 20000/1000, and not interchangeable. The official image creates its own user (9.1/alpine/Dockerfile):

addgroup -S -g 1000 valkey; \
adduser -S -G valkey -u 999 valkey

— gid 1000 because Alpine already uses gid 999. The image sets no USER and starts as root; its entrypoint then steps down by itself, and on the alpine flavour it does so with setpriv (the Debian flavour uses gosu):

if [ "$1" = 'valkey-server' ]; then
  if [ "$(id -u)" = '0' ]; then
    find . \! -user valkey -exec chown valkey '{}' +
    exec setpriv --reuid=valkey --regid=valkey --clear-groups -- "$0" "$@"

Running the pod directly as uid 999 — which runAsNonRoot requires — skips that branch, including its chown of /data. So fsGroup: 1000 is not decoration: it is the only thing that makes the volume writable. Change one of the three (runAsUser, runAsGroup, fsGroup) and the instance either refuses to start or cannot write its append-only file.

The server is started through the image's entrypoint with args: [/etc/valkey/valkey.conf], not with command: — the entrypoint prepends valkey-server for anything ending in .conf, and replacing it would lose tini as PID 1.

What a green pod proves, and why -e is in the command

All three probes run the same script, which authenticates with the password from the mounted file and requires the answer PONG:

VALKEYCLI_AUTH="$pass" valkey-cli -e -p 6379 ping | grep -qx PONG

Both halves matter, and the reason is the most expensive bug class in this repository:

  • valkey-cli ping exits 0 on an error reply unless -e is given. In valkey 9.1.2 (src/valkey-cli.c) an error reply becomes a non-zero exit in exactly one branch, guarded by config.set_errcode, and -e is the only thing that sets it. A probe written as a bare valkey-cli ping therefore prints NOAUTH Authentication required. and reports success — an assertion that cannot fail.
  • grep -qx PONG asserts the answer, not that the command returned. Either half alone would catch a NOAUTH; -e additionally sends the server's own message (NOAUTH…, WRONGPASS…) to stderr, where kubectl describe shows it in the probe's failure output.
  • The password goes through VALKEYCLI_AUTH, never -a <password> — an argument is readable in the process list, and valkey-cli -a says so itself.

So Ready means: the server accepted a connection, accepted that password, and answered. It does not mean the dataset is intact — a restarted instance whose AOF was lost answers PONG on an empty database — and it says nothing about whether the consuming application's connection URL is correct.

To check by hand without putting the password in your shell history:

kubectl -n <namespace> exec <release>-valkey-sfs-0 -- /bin/sh /etc/valkey/ping.sh && echo OK

One instance, on purpose

valkey.replicas is capped at 1 by the schema. There is no cluster mode and no replication: two pods would fight over the same ReadWriteOnce volume, and clients behind the Service would silently talk to two different datasets. Scaling this chart means another release for another application, not another replica.

Rotating the password does not restart the pod

The pod template carries checksum/config over the configuration ConfigMap and checksum/secret-ref over the rendered OnePasswordItem — so a changed maxmemory or eviction policy rolls the pod, and so does pointing secrets.authSecretRef at a different item. A new password inside the same item cannot be covered: Helm never sees a Secret the 1Password operator fills in (style guide §9). After such a rotation:

kubectl -n <namespace> rollout restart statefulset/<release>-valkey-sfs

operator.1password.io/auto-restart is deliberately not set: the operator restarts Deployments only, so on a StatefulSet it is a silent no-op that would advertise a guarantee that does not exist.

One side effect, written down as unverified rather than promised: because the probes read the password out of the same mounted file, the kubelet's refresh of that file (roughly a minute) should make the liveness probe fail against the still-old server and restart the container by itself. Do the rollout restart rather than waiting for it — and mind that the rotation is only complete when the consuming application's connection URL has been changed too.

The NetworkPolicy is only a boundary where the CNI enforces one

The API server accepts a NetworkPolicy whatever the cluster does with it. Where the CNI does not enforce policies, everything looks configured and nothing is filtered — which is worse than no policy, because it invites the assumption that the password is the second line of defence rather than the only one. Verify from an unrelated namespace instead of reading it off the manifest:

kubectl -n <other-namespace> run t --rm -it --image=docker.io/valkey/valkey:<appVersion> \
  -- valkey-cli -h <release>-valkey-service.<namespace> ping

That must hang or be refused. NOAUTH Authentication required. means the policy is not being enforced.

The tag moves, and that is accepted

appVersion is 9.1.2-alpine, and no tag of this image is immutable: upstream rebuilds the whole matrix periodically, including the fully numbered -alpine3.24 variants (9.1-alpine and 9.0.6-alpine3.24 were pushed minutes apart on 2026-09-21, two different release lines). So a rebuild changes the running image with no commit in the repository and nothing reports it.

The -alpine tag was chosen over -alpine3.24 precisely because both move equally, while only -alpine keeps a Renovate rule that cannot freeze: docker versioning splits at the first dash and offers only candidates with the same suffix, and upstream ships exactly one Alpine base per release — so a base change arrives as 9.1.3-alpine3.25, which a rule bound to alpine3.24 would never see. The remedy for the moving tag, if it ever matters, is pinDigests for this one image; see Renovate.

This chart is also a subchart — do not change it together with its consumers

Since Gitea #65 stalwart takes this chart as a dependency, and since Gitea #78 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.

The dependency resolves from the published index, not from the working tree, and this chart is published only by a push to main that touches it. So a PR carrying both runs every helm command — lint, package, render and the install-test — against the old valkey .tgz: it goes green on an artifact that is not the one in the diff, and the change here is reviewed while nothing has exercised it. Change valkey/, merge, let it publish, and only then raise the version: in the consumer.

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. 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 runs this chart as a cache (persistence.enabled: false + allkeys-lru), 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

  • A Valkey upgrade is a restart, and a restart is a data question, not a version question. With persistence.enabled: false the dataset is gone; with AOF it is replayed. Plan the upgrade of a queue instance for a moment when losing in-flight work would be tolerable anyway.
  • Raise valkey.startupProbe.failureThreshold before an instance's AOF grows large, not after the crash loop has started: the budget is 150s by default, and replaying a multi-GB file takes longer.
  • helm uninstall does not delete the PVC. The data survives a reinstall under the same release name; a release recreated under a different name starts empty while the old volume keeps occupying (never-reclaimed, on encrypted Longhorn) space.
  • Migrating an application off cluster-redis has a fixed order: roll out the instance → repoint the connection-url in the application's vault item → restart its pods → verify → last remove the ACL user from users.acl, so a fallback stays possible. Outline is nearly free (three keys); the three Paperless instances only after 3.2.0, so two causes do not mix; OneUptime last, because moving it means draining its queues or accepting the loss.

CI

ci/valkey/ holds test-values.yaml and fixtures.yaml (the one Secret the operator would materialize). The install-test runs the persistent variant — the risky path: a provisioned PVC, the pod's fsGroup against it, and an append-only file created on a volume the container did not create.

Two limits of that coverage, named so they are not mistaken for tested:

  • The machinery runs one variant per chart, so the non-persistent path is covered by a local render only.
  • fsGroup itself is not proven. k3d's local-path volumes are node-directory backed and created world-writable, so the container can write whether or not the kubelet applied fsGroup. The test proves writability, not the ownership mechanism. (A hostPath PV fixture would be worse: the kubelet does not apply fsGroup to those at all.)

The Trivy scan carries two documented exceptions for this chart (ci/trivy/trivyignore.yaml), both keyword matches against the ConfigMap's comments and shell code rather than data: KSV-0109 ("ConfigMap stores secrets") matches the probe script's pass="$(sed …)" line and a comment containing the word password, KSV-01010 matches the key name valkey.conf. The ConfigMap holds no credential — which is the whole reason the password arrives as a mounted Secret file. Worth knowing before the next reader takes the HIGH finding at face value on the one chart designed to avoid it.

See also