Almost every Neo4j cluster we are asked to review in 2026 runs on Kubernetes, and almost every one of them was built by someone who deployed the Helm chart once, saw three green pods, and never touched it again. Then the first rolling upgrade arrives — Neo4j now ships CalVer releases roughly monthly — and the cluster loses quorum, or a pod restarts onto an empty volume, or a "read replica" turns out to have been serving stale data for a week.
This tutorial walks the whole lifecycle: deploying a three-member autonomous cluster with the official Helm charts, adding secondaries, checking that the topology is actually what you think it is, and performing a zero-downtime rolling upgrade. It assumes Neo4j Enterprise (clustering is an Enterprise feature) and a working kubectl against a cluster with a dynamic storage class.
What "autonomous clustering" changes
If your mental model is still Neo4j 4.x — core servers and read replicas configured per-database in neo4j.conf — update it. Since 5.x, servers join a cluster as a pool of hosts, and databases are allocated across that pool with a topology you declare in Cypher:
CREATE DATABASE orders
TOPOLOGY 3 PRIMARIES 2 SECONDARIES;
The consequences matter for Kubernetes:
- Servers are interchangeable; the cluster decides which ones host which database. A pod is not "the leader" — a pod currently hosts a primary for a given database.
- Adding capacity is scaling a StatefulSet plus
ENABLE SERVER, not editing config on every node. systemis replicated to every server; your business databases are not.- Quorum is per-database, over its primaries. Three primaries tolerate one loss. Two primaries tolerate none — never run an even primary count.
Step 1 — the Helm values that actually matter
Add the repo and start from an explicit values file rather than the chart defaults:
helm repo add neo4j https://helm.neo4j.com/neo4j
helm repo update
# values-primary.yaml
neo4j:
name: graphguru # SAME name for every member of the cluster
edition: enterprise
acceptLicenseAgreement: "yes"
minimumClusterSize: 3
passwordFromSecret: neo4j-auth
resources:
cpu: "4"
memory: "16Gi"
volumes:
data:
mode: dynamic
dynamic:
storageClassName: premium-ssd
requests:
storage: 500Gi
config:
server.memory.heap.initial_size: "6G"
server.memory.heap.max_size: "6G"
server.memory.pagecache.size: "7G"
db.tx_log.rotation.retention_policy: "3 days"
initial.dbms.default_primaries_count: "3"
initial.dbms.default_secondaries_count: "0"
podSpec:
podAntiAffinity: true # keep members off the same node
Points people get wrong:
| Setting | Why it bites |
|---|---|
neo4j.name differing between releases | Members never discover each other; you get three one-node clusters |
minimumClusterSize left at 1 | The first pod forms a cluster of one and the others are refused |
| Heap + pagecache ≈ container memory | The kernel OOM-kills the pod mid-transaction; leave ~20% headroom |
volumes.data.mode: defaultStorageClass in prod | You inherit whatever the platform team's default is, often a slow or non-retaining class |
No podAntiAffinity | Two primaries land on one node; that node drains and you lose quorum |
Install three releases sharing the one name:
kubectl create secret generic neo4j-auth \
--from-literal=NEO4J_AUTH=neo4j/$(openssl rand -base64 24)
for i in 1 2 3; do
helm install graphguru-$i neo4j/neo4j \
-f values-primary.yaml --set neo4j.name=graphguru
done
Then the headless service that clients and members use for discovery:
helm install graphguru-lb neo4j/neo4j-headless-service \
--set neo4j.name=graphguru
Step 2 — verify the topology, do not assume it
kubectl exec graphguru-1-0 -- cypher-shell -u neo4j -p "$PW" "SHOW SERVERS"
SHOW SERVERS
YIELD name, address, state, health, hosting
RETURN name, address, state, health, hosting;
Every server should be Enabled / Available. A server stuck in Free has joined the cluster but hosts nothing — enable it explicitly:
ENABLE SERVER 'ec7ad2d0-2c02-4e1a-b8fa-1c1a1d0b2f0e';
And per database:
SHOW DATABASE orders
YIELD name, serverID, address, role, writer, currentStatus;
Look for exactly one writer: true, three rows with role: primary, and currentStatus: online everywhere. Two writers means you are looking at a split-brain artefact of a broken discovery config; zero means the database has no quorum.
Step 3 — secondaries for read scale
Secondaries are catch-up-only copies. They are ideal for analytics and dashboards, and dangerous for read-your-own-writes flows because they are asynchronous.
helm install graphguru-4 neo4j/neo4j -f values-secondary.yaml --set neo4j.name=graphguru
ENABLE SERVER 'server-4-uuid';
ALTER DATABASE orders SET TOPOLOGY 3 PRIMARIES 1 SECONDARY;
Route reads to them from the driver rather than by pointing at a pod:
with driver.session(database="orders",
default_access_mode=neo4j.READ_ACCESS) as session:
session.run("MATCH (o:Order) RETURN count(o)")
The driver's routing table sends read transactions to secondaries automatically. If your dashboards still hammer the writer, the cause is almost always session.run() outside an explicit read transaction, or a bolt:// URI instead of neo4j://.
Step 4 — the rolling upgrade
The whole point of a cluster is that you can upgrade it without downtime. The order is not negotiable.
- Back up first. Take a full backup and confirm you can restore it — see Backups You Can Actually Restore. An upgrade with no tested restore is a gamble, not a plan.
- Read the changelog for every version you are skipping. With CalVer you may be crossing several; our note on CalVer, Cypher 25, and GQL explains what changes between them.
- Upgrade secondaries first, one at a time, waiting for each to return to
onlinebefore the next. - Upgrade the primaries that are not the writer.
- Hand off the writer last:
kubectl delete podon the writer's pod triggers a leader transfer; clients retry through the driver.
helm upgrade graphguru-4 neo4j/neo4j -f values-secondary.yaml \
--set image.tag=2026.08.0 --set neo4j.name=graphguru
kubectl rollout status statefulset/graphguru-4
# wait for green, then repeat per member
kubectl exec graphguru-1-0 -- cypher-shell -u neo4j -p "$PW" \
"SHOW DATABASES YIELD name, currentStatus WHERE currentStatus <> 'online' RETURN *"
Between each step that query must return zero rows. If it does not, stop — do not upgrade the next member into a cluster that has not finished catching up.
Rehearse the sequence on a copy of production first. If you are still on 4.4, the upgrade is a migration, not a rolling restart: follow the 90-day migration plan instead.
Step 5 — survive node drains and disk pressure
Kubernetes will move your pods whether you are ready or not. Three settings make that survivable:
# PodDisruptionBudget: never voluntarily evict two members at once
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: graphguru-pdb
spec:
maxUnavailable: 1
selector:
matchLabels:
app: graphguru
- Retain the PVC. Set the storage class
reclaimPolicy: Retain. A deleted release that takes its volume with it is the single most common way people lose a Neo4j cluster on Kubernetes. - Give the readiness probe room. A member recovering from an unclean shutdown replays transaction logs; a 30-second probe kills it repeatedly and it never finishes. Raise
startupProbe.failureThresholdwell past your worst observed recovery time. - Watch disk headroom. Transaction log retention plus backup staging can double the data directory. Alert at 70%, not 90%.
Step 6 — what to monitor
Scrape the Prometheus endpoint (server.metrics.prometheus.enabled=true) and alert on:
| Metric | Alert when | Means |
|---|---|---|
neo4j_cluster_raft_is_leader summed per database | ≠ 1 | No writer, or split brain |
neo4j_cluster_catchup_tx_pull_requests_received flat on a secondary | > 5 min | Secondary silently stale |
neo4j_page_cache_hit_ratio | < 0.98 | Working set no longer fits; see PROFILE tuning |
neo4j_vm_heap_used sawtooth near max | sustained | Heap pressure, GC pauses, election risk |
neo4j_check_point_duration | growing | Slow disk, upcoming write stalls |
Pair these with the broader review items in our Neo4j health check checklist.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Three separate single-node clusters | neo4j.name differs per release | Reinstall with one shared name |
| Pod restarts to an empty database | PVC reclaim policy Delete | Retain, and restore from backup |
Unable to get bolt address of leader | Client uses bolt:// to one pod | Use neo4j:// against the headless service |
| Cluster loses quorum during node drain | No PDB or anti-affinity | Add both; spread over zones |
| Upgrade stalls halfway | Next member restarted before catch-up | Gate each step on SHOW DATABASES |
| Random OOMKills under load | Heap + pagecache ≈ limit | Cut pagecache, leave headroom |
Where to start tomorrow
Run SHOW SERVERS and SHOW DATABASES against your existing cluster and compare the answer to what you believe is deployed. In most reviews we run, at least one of these is true: a server is Free and hosting nothing you are paying for, a secondary has stopped catching up, or the PVCs would not survive a helm uninstall. Then rehearse one rolling upgrade on a clone before the next release lands.
If you would like a second pair of eyes on a Kubernetes-hosted cluster — sizing, topology, upgrade runbook, or a production readiness review before go-live — our senior Neo4j architects and consultants do this work every week. Get in touch with your topology and version and we will tell you what we would change.