Deploying Valkey on Kubernetes with Helm
Chat2DB TeamValkey is the Linux Foundation fork of Redis, created in 2024 after Redis moved away from BSD licensing. It is backed by AWS, Google, Oracle and Ericsson, remains BSD-licensed, and is a drop-in replacement — same RESP protocol, same commands, same client libraries. If you are deploying a Redis-compatible cache on Kubernetes today and want unambiguous open-source licensing, Valkey is the straightforward choice.
This guide covers deploying it with Helm, choosing between the three topologies, getting persistence and failover right, and a local Docker Compose setup for development.
Local development first
Before Kubernetes, the simplest possible setup — useful for development and for understanding the moving parts:
# docker-compose.yml
services:
valkey:
image: valkey/valkey:8-alpine
container_name: valkey
ports:
- "6379:6379"
command: >
valkey-server
--appendonly yes
--appendfsync everysec
--maxmemory 512mb
--maxmemory-policy allkeys-lru
--requirepass ${VALKEY_PASSWORD:-devpassword}
volumes:
- valkey-data:/data
healthcheck:
test: ["CMD", "valkey-cli", "-a", "${VALKEY_PASSWORD:-devpassword}", "ping"]
interval: 10s
timeout: 3s
retries: 5
volumes:
valkey-data:docker compose up -d
docker compose exec valkey valkey-cli -a devpassword ping
# PONGThe CLI is valkey-cli, but redis-cli works against it identically — the protocol is the same.
A replicated setup locally, if you want to exercise failover logic:
services:
valkey-primary:
image: valkey/valkey:8-alpine
command: valkey-server --appendonly yes --requirepass devpassword --masterauth devpassword
ports: ["6379:6379"]
volumes: ["primary-data:/data"]
valkey-replica:
image: valkey/valkey:8-alpine
command: >
valkey-server --appendonly yes --requirepass devpassword --masterauth devpassword
--replicaof valkey-primary 6379
ports: ["6380:6379"]
volumes: ["replica-data:/data"]
depends_on: [valkey-primary]
volumes:
primary-data:
replica-data:docker compose exec valkey-replica valkey-cli -a devpassword INFO replication
# role:slave
# master_link_status:upNote --masterauth: replicas need the password to authenticate to the primary, separately from --requirepass which governs clients connecting to them. Forgetting it is the most common reason a replica never syncs.
Choosing a topology
Three options, and picking the right one saves considerable trouble.
Standalone — one pod. No replication, no failover. If the pod dies, the cache is briefly unavailable and (without persistence) empty when it returns. Perfectly acceptable for a cache whose loss is a performance event rather than a correctness event. Simplest to run.
Replication (primary + replicas) — one writable primary, N read-only replicas, with Sentinel for automatic failover. Data fits on one node; replicas provide read scaling and high availability. This is the right default for most production caches.
Cluster — the keyspace is sharded across 16,384 hash slots spread over multiple primaries, each with replicas. Use it when the dataset exceeds one node's memory or write throughput exceeds one primary. The cost is real: multi-key operations only work when keys hash to the same slot, which forces hash tags like user:{1234}:profile into your key design, and transactions and Lua scripts cannot span slots.
The honest guidance is to avoid cluster mode unless you need it. A large amount of unnecessary complexity gets adopted because "cluster" sounds more production-ready. Replication with Sentinel handles high availability; cluster is for when a single node genuinely cannot hold the data.
Deploying with Helm
The Bitnami chart is the most widely used:
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
helm search repo bitnami/valkey --versions | headStandalone
# values-standalone.yaml
architecture: standalone
auth:
enabled: true
existingSecret: valkey-auth
existingSecretPasswordKey: password
primary:
persistence:
enabled: true
size: 8Gi
storageClass: gp3
resources:
requests:
cpu: 250m
memory: 1Gi
limits:
memory: 2Gi
configuration: |-
maxmemory 1500mb
maxmemory-policy allkeys-lru
appendonly yes
appendfsync everysec
metrics:
enabled: true
serviceMonitor:
enabled: trueCreate the secret out-of-band rather than putting a password in values:
kubectl create secret generic valkey-auth \
--from-literal=password="$(openssl rand -base64 32)" \
-n cache
helm install valkey bitnami/valkey \
-f values-standalone.yaml \
-n cache --create-namespaceReplication with Sentinel
# values-replication.yaml
architecture: replication
auth:
enabled: true
existingSecret: valkey-auth
existingSecretPasswordKey: password
sentinel:
enabled: true
quorum: 2
downAfterMilliseconds: 10000
failoverTimeout: 30000
resources:
requests:
cpu: 100m
memory: 128Mi
primary:
persistence:
enabled: true
size: 20Gi
storageClass: gp3
resources:
requests:
cpu: 500m
memory: 4Gi
limits:
memory: 6Gi
configuration: |-
maxmemory 3gb
maxmemory-policy allkeys-lru
appendonly yes
appendfsync everysec
replica:
replicaCount: 2
persistence:
enabled: true
size: 20Gi
storageClass: gp3
resources:
requests:
cpu: 500m
memory: 4Gi
limits:
memory: 6Gi
podAntiAffinityPreset: hard
metrics:
enabled: true
serviceMonitor:
enabled: trueSeveral details here matter.
sentinel.quorum: 2 with three Sentinels (one per pod) means two must agree the primary is down before failover. With replicaCount: 2 you get three pods total and three Sentinels — an odd number, which is what you want to avoid split-brain.
podAntiAffinityPreset: hard forces pods onto different nodes. Without it, Kubernetes may schedule your primary and both replicas onto the same node, and losing that node loses everything. This single line is the difference between real high availability and the appearance of it.
Memory limits should sit comfortably above maxmemory. Valkey needs headroom for client buffers, replication buffers and AOF rewrite; if the container limit equals maxmemory, the kernel OOM-kills the pod instead of Valkey evicting keys. A limit around 1.5–2x maxmemory is a reasonable starting point.
helm install valkey bitnami/valkey -f values-replication.yaml -n cache
kubectl get pods -n cache -wConnecting from an application — Sentinel-aware clients need the Sentinel service, not the primary directly:
valkey-headless.cache.svc.cluster.local:26379 # Sentinel
valkey-primary.cache.svc.cluster.local:6379 # direct primary
valkey-replicas.cache.svc.cluster.local:6379 # read-only replicasfrom redis.sentinel import Sentinel
sentinel = Sentinel(
[("valkey-headless.cache.svc.cluster.local", 26379)],
socket_timeout=0.5,
sentinel_kwargs={"password": PASSWORD},
password=PASSWORD,
)
primary = sentinel.master_for("mymaster", socket_timeout=0.5)
replica = sentinel.slave_for("mymaster", socket_timeout=0.5)
primary.set("key", "value")
print(replica.get("key"))Using a Sentinel-aware client is the point of running Sentinel. If your application connects straight to valkey-primary and a failover occurs, the service will eventually point at the new primary but your client will see errors in the meantime and may not reconnect cleanly.
Cluster mode
# values-cluster.yaml
architecture: replication # the cluster chart is separate; see note belowFor cluster mode use the dedicated chart:
# values-cluster.yaml for bitnami/valkey-cluster
cluster:
nodes: 6 # 3 primaries + 3 replicas
replicas: 1
auth:
enabled: true
existingSecret: valkey-auth
existingSecretPasswordKey: password
persistence:
enabled: true
size: 20Gi
storageClass: gp3
resources:
requests:
cpu: 500m
memory: 4Gi
limits:
memory: 6Gi
podAntiAffinityPreset: hard
metrics:
enabled: truehelm install valkey-cluster bitnami/valkey-cluster -f values-cluster.yaml -n cache
# Verify the cluster formed
kubectl exec -n cache valkey-cluster-0 -- \
valkey-cli -a "$PASSWORD" CLUSTER INFO
kubectl exec -n cache valkey-cluster-0 -- \
valkey-cli -a "$PASSWORD" CLUSTER NODEScluster_state:ok and all 16,384 slots assigned means it worked. Clients must use cluster mode:
from redis.cluster import RedisCluster
rc = RedisCluster(
host="valkey-cluster.cache.svc.cluster.local",
port=6379,
password=PASSWORD,
decode_responses=True,
)
rc.set("user:{1234}:profile", "...") # hash tag keeps related keys together
rc.set("user:{1234}:settings", "...") # same slot — MGET across them worksThe braces are the hash tag: only the text inside them is hashed, so both keys land on the same slot and multi-key operations between them are legal.
The Valkey operator
For fleets of Valkey instances, an operator manages them as custom resources rather than Helm releases:
apiVersion: hyperspike.io/v1
kind: Valkey
metadata:
name: app-cache
namespace: cache
spec:
nodes: 3
replicas: 1
storage:
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: gp3
resources:
requests:
storage: 20Gi
resources:
requests:
cpu: 500m
memory: 4Gi
limits:
memory: 6Gi
tls: true
prometheus: trueThe trade-off is the familiar one. A Helm chart is a templated set of manifests: easy to reason about, easy to helm template and inspect, no cluster-wide controller. An operator adds ongoing reconciliation — it can handle scaling, failover, certificate rotation and upgrades as first-class operations rather than a helm upgrade and hope.
For one or two instances, Helm is simpler and entirely sufficient. For a platform team running Valkey for many tenants, an operator pays for itself.
Persistence
Whether you need persistence at all is worth deciding deliberately.
Pure cache — data is reconstructible from the source of truth. Disable persistence, save the disk and the I/O. A restart means a cold cache and a brief load spike on the database, which is usually acceptable.
Session store, rate limiter, queue — losing data has user-visible consequences. Enable AOF.
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mbappendfsync everysec loses at most one second of writes on a hard crash, without the throughput penalty of always. RDB snapshots can be enabled alongside AOF as a faster-loading fallback.
The Kubernetes-specific concern is that a StatefulSet with ReadWriteOnce volumes ties each pod to a specific volume in a specific availability zone. If that zone goes away, the pod cannot be rescheduled elsewhere with its data. For a cache this is fine — let it start empty. For anything you actually need, rely on replication across zones rather than on the volume.
Use topologySpreadConstraints to spread across zones:
replica:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: valkeyMonitoring
With metrics.enabled: true and Prometheus Operator installed, a ServiceMonitor is created automatically. The signals worth alerting on:
| Metric | Watch for |
|---|---|
valkey_up | instance down |
valkey_connected_clients | approaching maxclients |
valkey_memory_used_bytes / maxmemory | above ~90% |
valkey_evicted_keys_total | rising — working set exceeds memory |
valkey_keyspace_hits_total vs misses | falling hit rate |
valkey_connected_slaves | below expected replica count |
valkey_master_link_up | replication broken |
valkey_rdb_last_bgsave_status | failed snapshots |
A hit-rate alert:
rate(valkey_keyspace_hits_total[5m])
/ (rate(valkey_keyspace_hits_total[5m]) + rate(valkey_keyspace_misses_total[5m]))
< 0.8Quick checks from the shell:
POD=valkey-primary-0
kubectl exec -n cache $POD -- valkey-cli -a "$PASSWORD" INFO replication
kubectl exec -n cache $POD -- valkey-cli -a "$PASSWORD" INFO memory
kubectl exec -n cache $POD -- valkey-cli -a "$PASSWORD" --bigkeys
kubectl exec -n cache $POD -- valkey-cli -a "$PASSWORD" SLOWLOG GET 10--bigkeys is the first thing to run when memory is higher than expected.
To browse the keyspace from your laptop, port-forward and connect any Redis-compatible client:
kubectl port-forward -n cache svc/valkey-primary 6379:6379Chat2DB (opens in a new tab) connects to Valkey over the standard protocol alongside PostgreSQL, MySQL and others, which is convenient when you are tracing a cache-versus-database consistency problem across both; the web version (opens in a new tab) avoids a local install.
Security
auth:
enabled: true
existingSecret: valkey-auth
tls:
enabled: true
authClients: false
existingSecret: valkey-tls
certFilename: tls.crt
certKeyFilename: tls.key
certCAFilename: ca.crt
networkPolicy:
enabled: true
allowExternal: falseThree things, in order of importance. Always enable auth — an unauthenticated cache reachable inside the cluster is reachable by every compromised pod in it. Enable a NetworkPolicy with allowExternal: false so only labelled workloads can reach port 6379. Enable TLS if traffic crosses node or zone boundaries and you do not have a service mesh already encrypting it.
Also consider disabling destructive commands:
rename-command FLUSHALL ""
rename-command FLUSHDB ""Summary
For most teams the right Valkey deployment on Kubernetes is replication mode with Sentinel, three pods with hard anti-affinity, AOF persistence if the data matters, metrics enabled, auth enabled, and a NetworkPolicy. That configuration survives a node failure, scales reads, and stays comprehensible.
The two mistakes that cause the most pain are reaching for cluster mode when replication would do — inheriting hash-tag constraints for no benefit — and omitting pod anti-affinity, which produces a topology that looks highly available until the node holding all three pods reboots. Get those two right and the rest is tuning.
