+1 (415) 649-9454

Neo4j on Kubernetes: Autonomous Clustering, Helm, and Zero-Downtime Rolling Upgrades

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.
  • system is 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:

SettingWhy it bites
neo4j.name differing between releasesMembers never discover each other; you get three one-node clusters
minimumClusterSize left at 1The first pod forms a cluster of one and the others are refused
Heap + pagecache ≈ container memoryThe kernel OOM-kills the pod mid-transaction; leave ~20% headroom
volumes.data.mode: defaultStorageClass in prodYou inherit whatever the platform team's default is, often a slow or non-retaining class
No podAntiAffinityTwo 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.

  1. 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.
  2. 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.
  3. Upgrade secondaries first, one at a time, waiting for each to return to online before the next.
  4. Upgrade the primaries that are not the writer.
  5. Hand off the writer last: kubectl delete pod on 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.failureThreshold well 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:

MetricAlert whenMeans
neo4j_cluster_raft_is_leader summed per database≠ 1No writer, or split brain
neo4j_cluster_catchup_tx_pull_requests_received flat on a secondary> 5 minSecondary silently stale
neo4j_page_cache_hit_ratio< 0.98Working set no longer fits; see PROFILE tuning
neo4j_vm_heap_used sawtooth near maxsustainedHeap pressure, GC pauses, election risk
neo4j_check_point_durationgrowingSlow disk, upcoming write stalls

Pair these with the broader review items in our Neo4j health check checklist.

Common failure modes

SymptomCauseFix
Three separate single-node clustersneo4j.name differs per releaseReinstall with one shared name
Pod restarts to an empty databasePVC reclaim policy DeleteRetain, and restore from backup
Unable to get bolt address of leaderClient uses bolt:// to one podUse neo4j:// against the headless service
Cluster loses quorum during node drainNo PDB or anti-affinityAdd both; spread over zones
Upgrade stalls halfwayNext member restarted before catch-upGate each step on SHOW DATABASES
Random OOMKills under loadHeap + pagecache ≈ limitCut 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.