Backup and Restore¶
Back up and restore Neo4j Enterprise clusters and standalone instances via the Neo4jBackup and Neo4jRestore CRDs. Both deployment kinds are auto-detected by the operator from instanceRef.
Check the preconditions before the first run
A backup Job that cannot authenticate fails at run time, and the kubelet's error does not
mention backups. kubectl neo4j preflight -f <manifest> checks the shape first — the
credentials Secret holds every key the Job mounts, or the ServiceAccount carries the cloud
identity it needs. See the CLI guide.
Quick Start¶
kubectl create secret generic neo4j-admin-secret \
--from-literal=username=neo4j --from-literal=password=admin123
kubectl apply -f https://raw.githubusercontent.com/priyolahiri/neo4j-kubernetes-operator/main/examples/backup-restore/backup-pvc-simple.yaml
kubectl get neo4jbackups simple-backup -w
Watch for status.phase: Completed. Logs at kubectl logs job/simple-backup-backup.
Prerequisites¶
- Neo4j Enterprise 5.26.0+ (semver) or 2025.01.0+ (CalVer), enterprise image tag (
neo4j:X-enterprise) - Admin credentials Secret
- Storage backend: PVC, S3, GCS, or Azure
Tutorial: Your First Backup and Restore (~10 minutes)¶
A complete, copy-paste walk-through: from a running cluster to a verified backup and a verified restore, using a local PVC (no cloud account needed). Follow it top to bottom. Everything else in this guide (cloud storage, scheduling, differential chains, point-in-time recovery) is optional and comes after.
Before you start¶
You need a Neo4jEnterpriseCluster (or Neo4jEnterpriseStandalone) already in
phase Ready — deploy one from the Getting Started
guide first. Verify, using the cluster name from that guide:
1. Confirm the admin secret¶
The backup Job authenticates to Neo4j with the cluster's admin credentials —
the same neo4j-admin-secret your cluster already uses (you created it
when you deployed the cluster). It must live in the same namespace as the
cluster. Confirm it's there:
If it's missing (e.g. your cluster references a differently-named secret), create it with your cluster's password:
kubectl create secret generic neo4j-admin-secret \
--from-literal=username=neo4j --from-literal=password='<your-neo4j-password>'
2. (Optional) Add data, so you can prove the round-trip later¶
kubectl exec minimal-cluster-server-0 -c neo4j -- \
cypher-shell -u neo4j -p '<your-neo4j-password>' -d neo4j \
"CREATE (:Product {name: 'Widget'}) RETURN 1"
3. Take a backup¶
This backs up the neo4j database to a PVC the operator creates for you.
backupType: AUTO takes a full backup the first time, differentials after.
cat <<'EOF' | kubectl apply -f -
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata:
name: first-backup
spec:
instanceRef: minimal-cluster # a cluster OR standalone — auto-detected
database: neo4j # one database → one restorable artifact
storage:
type: pvc
pvc:
name: first-backup-pvc
size: 5Gi
options:
backupType: AUTO
EOF
4. Verify the backup completed¶
kubectl get neo4jbackup first-backup -o jsonpath='{.status.phase}{"\n"}'
# → Completed
kubectl get neo4jbackup first-backup -o jsonpath='{.status.history[0].artifactFilename}{"\n"}'
# → neo4j-2026-….backup (the captured artifact)
A one-shot backup reaches
Completed. If you add aschedule:field it becomes a recurring CronJob that stays in phaseScheduled— checkstatus.history[]for each run instead.The PVC (
first-backup-pvc) and its backups survive even if you delete theNeo4jBackupCR — reclaim the space withkubectl delete pvc first-backup-pvc.
5. Restore into a new database¶
Restore into a fresh database neo4jrestored on the same cluster. The operator
seeds it from the backup over Cypher — online, no downtime, and your original
neo4j database is untouched.
On a Neo4jEnterpriseStandalone
The same: a standalone restores online too, and its other databases stay
available throughout. A few restores run offline through
neo4j-admin, which stops the instance — a point-in-time restore over a
database that exists, for example. Those need stopCluster: true, and the restore's status names
the reason. See
Restore to a Standalone Instance.
cat <<'EOF' | kubectl apply -f -
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRestore
metadata:
name: first-restore
spec:
instanceRef: minimal-cluster
database: neo4jrestored
source:
type: backup
backupRef: first-backup
EOF
6. Verify the restore — and your data¶
kubectl get neo4jrestore first-restore -o jsonpath='{.status.phase}{"\n"}'
# → Completed
kubectl exec minimal-cluster-server-0 -c neo4j -- \
cypher-shell -u neo4j -p '<your-neo4j-password>' \
"SHOW DATABASE neo4jrestored YIELD name, currentStatus"
# → neo4jrestored online
kubectl exec minimal-cluster-server-0 -c neo4j -- \
cypher-shell -u neo4j -p '<your-neo4j-password>' -d neo4jrestored \
"MATCH (p:Product) RETURN p.name"
# → "Widget" (your step-2 data is back)
That's a full backup and restore. From here:
- Restoring one database from an instance-wide (
allDatabases) backup? Name it inspec.database(orsource.sourceDatabase, to restore it under another name); the operator reads its artifact from the backup's recorded per-database map. To restore them all, useallDatabases: true. See Restore Operations. - Standalone targets restore online like clusters, except the few cases that
run offline through a Job (
stopCluster: true). See Restore to a Standalone Instance. - Cloud storage (S3/GCS/Azure/MinIO) → Cloud Storage Authentication.
- Scheduled & differential backups → Backup Operations.
Migrating from legacy spec.backups¶
There is no spec.backups field (and no centralized {cluster}-backup-0 StatefulSet or standalone backup sidecar). Use one or more Neo4jBackup CRs instead. The Neo4jBackup CRD covers one-shot or scheduled (spec.schedule) backups, native CronJob retention/suspend, status.history, sharded-DB targets, mixed-cadence FULL+DIFF chains via spec.chainFromBackup, and per-Job pod resource control (spec.options.resources).
Backup & Restore API — scope, not topology¶
You describe what to back up/restore (scope) and which deployment (a topology-agnostic reference); the operator resolves cluster-vs-standalone itself.
Back up one database
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata: { name: customers-backup }
spec:
instanceRef: my-neo4j # a cluster OR a standalone — the operator resolves it
database: customers
storage: { type: s3, bucket: backups }
Back up every database (instance-wide; system is captured but never restored)
Restore — symmetric
kind: Neo4jRestore
spec:
instanceRef: my-neo4j
database: customers # or: allDatabases: true
source: { type: backup, backupRef: customers-backup }
Cross-topology restore. A backup is database-scoped, not topology-scoped — a database backed up from a standalone can be restored into a cluster (and vice-versa); the operator picks the engine from the target. Caveats: the target Neo4j version must be ≥ the source; the system database is never restored across topologies (an allDatabases restore skips it, although the backup captures it — carry users/roles via options.includeMetadata on the backup).
All-databases restore. Neo4jRestore.spec.allDatabases: true restores every user database recorded in the source backup, with per-database progress in status.databaseResults. Requires source.type=backup. The operator restores one database per reconcile pass via the online Cypher path (cloud and PVC-backed backups), on clusters and standalones alike; set options.replaceExisting: true to overwrite existing databases. A standalone falls back to a single offline neo4j-admin database restore Job covering all user databases — with stopCluster: true, which scales the instance to 0 — in the cases listed under Restore to a Standalone Instance, for example a point-in-time restore.
Migrating from the pre-v1.14 API (spec.target, spec.clusterRef, spec.databaseName, spec.force): see the Upgrade Guide.
Backup Architecture¶
The operator spawns a Kubernetes Job that runs neo4j-admin database backup from the same Neo4j Enterprise image as the cluster (no sidecar containers). The Job connects to each {cluster}-server-N Pod on port 6362 (server.backup.listen_address=0.0.0.0:6362, configured automatically). For cloud destinations, neo4j-admin streams directly to the bucket — set tempStorage for large databases that need a PVC for staging.
Backup Jobs run as the auto-created neo4j-backup-sa ServiceAccount in the same namespace. For Workload Identity (IRSA / GKE WI / Azure WI), annotate the SA via cloud.identity.autoCreate.annotations — see Cloud Storage Authentication.
Backup scope¶
Set instanceRef to the deployment (a Neo4jEnterpriseCluster or Neo4jEnterpriseStandalone — the operator auto-detects which) and choose exactly one scope:
| Scope field | Backs up |
|---|---|
allDatabases: true |
every standard database (neo4j-admin database backup "*" — one .backup artifact per database, system included — the all-databases restore never restores it) and catalogues each property-sharded family (status.shardedFamilies). The all-databases restore loop recreates standard DBs only; restore each sharded family from the same backup via its Neo4jShardedDatabase CR — see the note below. |
database: <name> |
a single standard database (e.g. neo4j) |
shardedDatabase: <cr-name> |
a single property-sharded database, named by its Neo4jShardedDatabase CR (the operator resolves the logical DB name from that CR's spec.name — the two often differ). All shards (graph + property) are captured in one neo4j-admin run. |
# Back up a property-sharded database
spec:
instanceRef: my-cluster
shardedDatabase: products-sharded # the Neo4jShardedDatabase CR name
storage: { type: s3, bucket: backups }
A standalone is just an instance — back it up with instanceRef: <standalone-name> + a scope, exactly like a cluster.
ℹ️
allDatabasesand property-sharded databases — one backup, two restore paths. An instance-wide backup catalogues each property-sharded family's per-shard.backupfiles instatus.history[].shardedFamilies(the family names are also listed inshardedDatabasesExcluded, with aBackupShardedDatabasesExcludedevent). So oneallDatabasesbackup is a complete DR source. What differs is how each kind is restored: the all-databases restore loop recreates standard databases only (sharded DBs need the shard-topologyCREATEclauses onlyNeo4jShardedDatabaseemits), so to recover a sharded family you re-apply itsNeo4jShardedDatabaseCR withspec.seedBackupRefpointing at that same backup (addspec.seedSourceDatabaseif restoring under a different name). The all-databases restore re-warns (RestoreShardedDatabasesNotCovered) naming each family and theseedBackupRefto use — see Property Sharding. Recovering a mixed cluster is therefore an all-databases restore plus oneNeo4jShardedDatabasere-apply per family, all seeding from the single backup. This holds for cloud storage. On a PVC, a sharded database seeds only from a run whose shards are all full backups, and an all-databases backup's runs after the first hold differentials — so back each sharded database up with its ownshardedDatabase:-scoped backup, which takes full backups on a PVC (see Restoring a sharded database).
Restore is single-database even when the backup was instance-wide (allDatabases): the backup leaves one artifact per database in the chain directory, and records each one in status.history[].databaseArtifacts. Reference the instance-wide backup and name the database (spec.database, or source.sourceDatabase to restore it under another name); the operator seeds from that database's recorded artifact, on clusters and standalones alike. (An offline standalone restore of a backup that recorded no map falls back to the newest <database>-*.backup in the directory.)
To restore every database at once, set allDatabases: true on the Neo4jRestore (see the scope API above).
Storage Layout¶
All runs of a single Neo4jBackup CR write to the same directory: <storage.path>/<cr-name>/. This is what neo4j-admin requires for differential backup chaining — every diff run reads the prior full from the same directory to compute the delta. Per-run identity is preserved by the timestamp neo4j-admin embeds in each artifact filename (<dbname>-YYYY-MM-DDThh-mm-ss.backup).
Each scheduled run appends to status.history[] with a unique runID (the Job name, e.g. daily-backup-backup-cron-1764806400 — handy for kubectl logs job/<runID>) and the captured artifact filename. To list runs:
Two different Neo4jBackup CRs pointing at the same bucket+path stay isolated by the per-CR segment. To chain runs for diff support, you must reuse the same CR (typically via spec.schedule).
Backup Types¶
| Type | Description | backupType value |
|---|---|---|
| Auto (default) | FULL on the first run, DIFF on subsequent runs (always FULL for a sharded database on a PVC — see Property Sharding) | AUTO |
| Full | Complete snapshot of all database files | FULL |
| Differential | Only pages changed since the last full backup | DIFF |
backupType defaults to AUTO when omitted. Differential backups are significantly smaller and faster for large databases. Because all runs share a directory, neo4j-admin auto-detects the chain — set backupType: DIFF and the operator points --to-path at the directory containing the prior full. Set preferDiffAsParent: true (CalVer 2025.04+) to chain diffs off the latest diff instead of the latest full.
Storage Backends¶
| Backend | Type | Best For |
|---|---|---|
| PVC | pvc |
Development, testing, air-gapped environments |
| AWS S3 | s3 |
Production on AWS |
| MinIO / S3-compatible | s3 + endpointURL |
On-premises, air-gapped, or self-hosted object storage |
| Google Cloud Storage | gcs |
Production on GCP |
| Azure Blob Storage | azure |
Production on Azure |
Cloud Storage Authentication¶
Cloud backup Jobs need permission to write to your bucket. The operator supports two authentication paths.
Choosing Your Authentication Method¶
| Scenario | Recommended Method |
|---|---|
| AWS EKS with IRSA configured | Workload Identity (IRSA) |
| GKE with Workload Identity enabled | Workload Identity (GKE WI) |
| AKS with Workload Identity enabled | Workload Identity (Azure WI) |
| Any cloud — quick setup, non-production | Explicit credentials via credentialsSecretRef |
| On-prem Kubernetes (no cloud IAM) | Explicit credentials via credentialsSecretRef |
| Compliance requires no long-lived keys | Workload Identity |
Recommendation: use pod identity (IRSA / GKE Workload Identity / Azure Workload Identity) wherever your platform supports it. Leaving credentialsSecretRef empty (or omitting the whole cloud credentials block) means the cloud SDK's default credential chain is used — which picks up the pod identity automatically. No long-lived keys, nothing to rotate. Explicit credentials work everywhere and are simpler to set up initially, but rotate them regularly and store them only in Kubernetes Secrets.
Which ServiceAccount needs the IAM binding? It depends on where the cloud access happens:
| Operation | Runs on | Bind the IAM role to |
|---|---|---|
Neo4jBackup, and a standalone Neo4jRestore that runs offline (Job paths) |
a backup/restore Job pod | the operator-managed neo4j-backup-sa / neo4j-restore-sa — via cloud.identity.autoCreate.annotations on the CR |
Online Neo4jRestore (Cypher path, clusters and standalones), Neo4jDatabase seedURI, sharded seedBackupRef seeding |
the Neo4j server pods (the JVM fetches the seed itself) | the server pods' ServiceAccount (cluster or standalone) — the Job SA annotation does nothing here |
Annotating the server pods' ServiceAccount (Workload Identity): set
spec.podServiceAccountAnnotations on the Neo4jEnterpriseCluster or
Neo4jEnterpriseStandalone — the operator stamps them onto the ServiceAccount
the Neo4j pods run under (a standalone's is <name>-neo4j, created only while
the field is set), so the JVM assumes the role for the seed fetch:
spec:
podServiceAccountAnnotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/neo4j-seed # AWS IRSA
# iam.gke.io/gcp-service-account: neo4j-seed@my-project.iam.gserviceaccount.com # GKE WI
The identity webhook injects the credentials at pod admission, so the pods must
start after the annotation is there. On a cluster, set it before the pods
start (or roll the StatefulSet after adding it). A standalone restarts its pod
whenever the field is set, changed or removed, and an online restore waits for
that restart. A standalone without it restores from cloud storage with no
credentialsSecretRef offline, through the restore Job, whose ServiceAccount
carries the identity.
With static credentials the equivalent split applies: Job paths read the Secret referenced by credentialsSecretRef; the seedURI paths need the same Secret projected onto the server pods via the cluster/standalone CR's spec.extraEnvFrom (the operator checks this and emits a copy-pasteable fix, or auto-patches under the neo4j.com/auto-inherit-seed-creds: "true" annotation).
AWS S3 Authentication¶
Path 1: Explicit Credentials¶
Create a Kubernetes Secret with your AWS credentials:
kubectl create secret generic aws-backup-creds \
--from-literal=AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE \
--from-literal=AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
--from-literal=AWS_REGION=us-east-1
The region lives in this Secret as
AWS_REGION— there is nospec.cloud.regionfield. ForcredentialsSecretRefbackups all three keys (AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION) are required; a missing key fails the backup Job pod. With IRSA/Workload Identity (no Secret) the SDK resolves the region ambiently.
Reference the Secret in your Neo4jBackup:
storage:
type: s3
bucket: my-neo4j-backups
path: cluster-backups
cloud:
provider: aws
credentialsSecretRef: aws-backup-creds
The operator injects AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_REGION from the Secret (each as a secretKeyRef) as environment variables in the backup Job pod, which neo4j-admin picks up automatically. Other keys in the Secret — including AWS_SESSION_TOKEN — are not passed to the Job.
Path 2: AWS IRSA (IAM Roles for Service Accounts)¶
Annotate the automatically-created neo4j-backup-sa ServiceAccount with your IAM role ARN:
storage:
type: s3
bucket: my-neo4j-backups
path: cluster-backups
cloud:
provider: aws
identity:
provider: aws
autoCreate:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/neo4j-backup-role
One workload identity per namespace.
neo4j-backup-sa(andneo4j-restore-sa) are shared by every backup/restore CR in the namespace — workload-identity trust policies bind to the ServiceAccount name. If two CRs declare differentautoCreate.annotations, the last one reconciled wins and the other's cloud access breaks; the operator flags this with aServiceAccountAnnotationConflictWarning event on the overwriting CR. Use one IAM role/identity with access to all backup locations in the namespace, or split the CRs across namespaces.
The IAM role must allow these actions on your bucket:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:ListBucket",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::my-neo4j-backups",
"arn:aws:s3:::my-neo4j-backups/*"
]
}
]
}
The IAM role must also have a trust relationship with the EKS OIDC provider for the neo4j-backup-sa ServiceAccount in your namespace.
MinIO and S3-Compatible Storage¶
MinIO is a high-performance, S3-compatible object store popular for on-premises and air-gapped Kubernetes environments. The operator supports MinIO (and other S3-compatible stores such as Ceph RGW and Cloudflare R2) using two additional fields on CloudBlock:
| Field | Purpose |
|---|---|
endpointURL |
Custom S3 API endpoint (injected as AWS_ENDPOINT_URL_S3) |
forcePathStyle |
Path-style addressing required by MinIO (injects -Daws.s3.forcePathStyle=true into the JVM) |
Step 1: Create the credentials Secret¶
MinIO uses the same secret key names as AWS. The AWS_REGION value is required by the SDK but ignored by MinIO — any value works.
kubectl create secret generic minio-backup-credentials \
--from-literal=AWS_ACCESS_KEY_ID=minioadmin \
--from-literal=AWS_SECRET_ACCESS_KEY=minioadmin \
--from-literal=AWS_REGION=us-east-1
Step 2: Create the backup resource¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata:
name: minio-backup
spec:
instanceRef: my-cluster
allDatabases: true
storage:
type: s3
bucket: neo4j-backups # bucket must already exist in MinIO
path: cluster/full
cloud:
provider: aws
credentialsSecretRef: minio-backup-credentials
endpointURL: http://minio.minio.svc:9000 # adjust namespace/port
forcePathStyle: true # required for MinIO
options:
backupType: FULL
compress: true
tempStorage:
size: "20Gi"
External MinIO over TLS: Change
endpointURLtohttps://minio.example.com. Ensure your MinIO TLS certificate is trusted by the container (or use a properly signed cert). Self-signed certs require mounting the CA into the pod — useadditionalArgsto pass--ssl-certificate-authoritiesif needed.
Verify the backup reached MinIO¶
kubectl run minio-client --rm -i --restart=Never \
--image=cgr.dev/chainguard/minio-client \
--env=MC_HOST_local=http://minioadmin:minioadmin@minio.minio.svc:9000 \
-- ls --recursive local/neo4j-backups/cluster/
MinIO does not publish public images — minio/mc on Docker Hub and
quay.io/minio/mc both require authentication — so this uses Chainguard's
build of the same client. It has no shell, which is why the alias comes from
the MC_HOST_local variable rather than mc alias set.
Troubleshooting MinIO¶
| Symptom | Likely cause | Fix |
|---|---|---|
NoSuchBucket |
Bucket not created | mc mb local/neo4j-backups |
connection refused |
Wrong endpoint URL | Verify endpointURL and MinIO pod readiness |
SignatureDoesNotMatch |
Wrong credentials | Check secret key values |
| Path-style not working | forcePathStyle missing |
Confirm forcePathStyle: true in spec |
SSL handshake failed |
TLS mismatch | Use http:// for in-cluster; mount CA for self-signed certs |
Restoring from MinIO / S3-compatible storage. Set
endpointURLandforcePathStyleon the backup'scloudblock; the operator injectsAWS_ENDPOINT_URL_S3and the path-style JVM flag into the backup and offline-restore Job pods automatically. The online restore path (clusters, and most standalone restores) makes the server pods fetch the seed, so they need the endpoint too — for a restore viabackupRef, the operator auto-projectsAWS_ENDPOINT_URL_S3(and the path-style JVM opt) onto the cluster's or standalone'sspec.envwhen you opt in with theneo4j.com/auto-inherit-seed-creds: "true"annotation (the same gate as seed credentials), waiting for the rolling restart before it seeds. Without the annotation it fails with an actionable error telling you to add it or set the annotation. You can always provide it yourself instead — as aspec.enventry or a key in the projected credentials Secret:kubectl create secret generic minio-backup-credentials \ --from-literal=AWS_ACCESS_KEY_ID=minioadmin \ --from-literal=AWS_SECRET_ACCESS_KEY=minioadmin \ --from-literal=AWS_REGION=us-east-1 \ --from-literal=AWS_ENDPOINT_URL_S3=http://minio.minio.svc:9000(
Neo4jDatabase/Neo4jShardedDatabaseseedURIseeding from a custom endpoint is not auto-projected — provide the endpoint via the cluster'sspec.envor the projected Secret there.)
Full examples with scheduled incremental backups: examples/backup-restore/backup-minio.yaml.
Google Cloud Storage Authentication¶
Path 1: Explicit Credentials (Service Account Key)¶
The key inside the Secret must be named GOOGLE_APPLICATION_CREDENTIALS_JSON:
kubectl create secret generic gcs-backup-creds \
--from-literal=GOOGLE_APPLICATION_CREDENTIALS_JSON="$(cat /path/to/service-account-key.json)"
Reference the Secret in your Neo4jBackup:
storage:
type: gcs
bucket: my-neo4j-backups
path: cluster-backups
cloud:
provider: gcp
credentialsSecretRef: gcs-backup-creds
The operator writes the JSON value to a file inside the backup Job pod and sets GOOGLE_APPLICATION_CREDENTIALS to point to it. neo4j-admin authenticates using the Application Default Credentials chain.
Path 2: GKE Workload Identity¶
Annotate the neo4j-backup-sa ServiceAccount with the GCP service account to impersonate:
storage:
type: gcs
bucket: my-neo4j-backups
path: cluster-backups
cloud:
provider: gcp
identity:
provider: gcp
autoCreate:
annotations:
iam.gke.io/gcp-service-account: neo4j-backup@my-project.iam.gserviceaccount.com
You must also bind the Kubernetes ServiceAccount to the GCP service account and grant the GCP SA storage access:
# Allow the Kubernetes SA to impersonate the GCP SA
gcloud iam service-accounts add-iam-policy-binding \
neo4j-backup@my-project.iam.gserviceaccount.com \
--role roles/iam.workloadIdentityUser \
--member "serviceAccount:my-project.svc.id.goog[my-namespace/neo4j-backup-sa]"
# Grant GCS objectAdmin to the GCP SA
gsutil iam ch \
serviceAccount:neo4j-backup@my-project.iam.gserviceaccount.com:objectAdmin \
gs://my-neo4j-backups
Replace my-namespace with the Kubernetes namespace where your Neo4jBackup resource lives.
Azure Blob Storage Authentication¶
Path 1: Explicit Credentials (Storage Account Key)¶
kubectl create secret generic azure-backup-creds \
--from-literal=AZURE_STORAGE_ACCOUNT=mystorageaccount \
--from-literal=AZURE_STORAGE_KEY=<your-storage-account-key>
Reference the Secret in your Neo4jBackup:
storage:
type: azure
bucket: neo4j-backups # This is the Azure container name
path: cluster-backups
cloud:
provider: azure
credentialsSecretRef: azure-backup-creds
Path 2: Azure Workload Identity¶
Annotate the neo4j-backup-sa ServiceAccount with your Azure client ID:
storage:
type: azure
bucket: neo4j-backups
path: cluster-backups
cloud:
provider: azure
identity:
provider: azure
autoCreate:
annotations:
azure.workload.identity/client-id: <AZURE_CLIENT_ID>
The Azure AD application / managed identity identified by AZURE_CLIENT_ID must have the Storage Blob Data Contributor role on the storage container (or account). Your AKS cluster must have the Azure Workload Identity webhook installed and the federated credential configured for the neo4j-backup-sa ServiceAccount.
Backup Operations¶
One-Time Backup Examples¶
Backup to PVC (Local Storage)¶
The simplest backup option — no cloud credentials needed:
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata:
name: simple-backup
spec:
instanceRef: single-node-cluster
allDatabases: true
storage:
type: pvc
pvc:
name: backup-storage
size: 50Gi
storageClassName: standard
options:
compress: true
validate: true
retention:
maxCount: 5
Best for: Development, testing, getting started, air-gapped environments.
PVC ownership: auto-provision vs bring-your-own¶
storage.pvc.size is the switch for who creates the PVC — but either way the PVC (and your backups) survive kubectl delete neo4jbackup:
| Pattern | YAML | Behavior |
|---|---|---|
| Operator auto-provisions | Set name + size (+ optional storageClassName) |
Operator creates the PVC if it doesn't exist. It is not owner-ref'd to the CR — backups are durable; reclaim the storage with an explicit kubectl delete pvc <name>. |
| Bring your own PVC | Set name only; omit size |
Operator just mounts the existing PVC. Pre-create it however you like (kubectl, Helm, Velero, static binding, NFS, etc.). If a name-only PVC doesn't exist, the backup Job can't schedule — the operator surfaces the reason in status and fails after a short deadline rather than hanging. |
Backups are never deleted by deleting the
Neo4jBackupCR. Reclaim backup storage explicitly withkubectl delete pvc. One PVC can be shared across multiple Neo4jBackup CRs (e.g. a daily + a weekly schedule writing to the same volume).
Cluster Backup to S3 with Explicit Credentials¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata:
name: cluster-backup-s3
spec:
instanceRef: my-neo4j-cluster
allDatabases: true
storage:
type: s3
bucket: my-backup-bucket
path: neo4j-backups/cluster
cloud:
provider: aws
credentialsSecretRef: aws-backup-creds
options:
compress: true
validate: true
tempStorage:
size: "50Gi"
retention:
maxAge: "30d"
maxCount: 10
Best for: Production AWS environments with static credentials. Swap credentialsSecretRef for identity.autoCreate.annotations to use IRSA instead.
Cluster Backup to S3 with IRSA¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata:
name: cluster-backup-s3-irsa
spec:
instanceRef: my-neo4j-cluster
allDatabases: true
storage:
type: s3
bucket: my-backup-bucket
path: neo4j-backups/cluster
cloud:
provider: aws
identity:
provider: aws
autoCreate:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/neo4j-backup-role
options:
compress: true
validate: true
tempStorage:
size: "50Gi"
retention:
maxAge: "30d"
maxCount: 10
Best for: Production EKS environments — no long-lived credentials stored in Secrets.
Cluster Backup to GCS with Explicit Credentials¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata:
name: cluster-backup-gcs
spec:
instanceRef: my-neo4j-cluster
allDatabases: true
storage:
type: gcs
bucket: my-gcs-backup-bucket
path: neo4j-backups/cluster
cloud:
provider: gcp
credentialsSecretRef: gcs-backup-creds
options:
compress: true
validate: true
tempStorage:
size: "50Gi"
retention:
maxAge: "30d"
maxCount: 10
Cluster Backup to Azure with Explicit Credentials¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata:
name: cluster-backup-azure
spec:
instanceRef: production-cluster
allDatabases: true
storage:
type: azure
bucket: neo4j-backups # Azure container name
path: cluster/production
cloud:
provider: azure
credentialsSecretRef: azure-backup-creds
options:
compress: true
validate: true
tempStorage:
size: "50Gi"
Database Backup Examples¶
To back up a specific database rather than the whole instance, set database: <name> alongside instanceRef — instanceRef identifies the cluster or standalone that owns the database.
Database Backup to GCS¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata:
name: database-backup-gcs
spec:
instanceRef: my-cluster # the cluster or standalone that owns the database
database: myapp-db # the database to back up
storage:
type: gcs
bucket: my-gcs-backup-bucket
path: neo4j-backups/myapp
cloud:
provider: gcp
credentialsSecretRef: gcs-backup-creds
options:
compress: true
validate: true
tempStorage:
size: "50Gi"
Database Backup to S3¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata:
name: database-backup-s3
spec:
instanceRef: production-cluster
database: production-db
storage:
type: s3
bucket: my-backup-bucket
path: databases/production-db
cloud:
provider: aws
credentialsSecretRef: aws-backup-creds
options:
compress: true
validate: true
tempStorage:
size: "50Gi"
Differential Backups¶
Differential backups capture only the pages changed since the last full backup, making them faster and smaller:
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata:
name: differential-backup
spec:
instanceRef: my-neo4j-cluster
allDatabases: true
storage:
type: s3
bucket: my-backup-bucket
path: neo4j-backups/differential
cloud:
provider: aws
credentialsSecretRef: aws-backup-creds
options:
backupType: DIFF
compress: true
validate: true
tempStorage:
size: "50Gi"
Mixed-cadence backups: daily FULL + hourly DIFF¶
spec.chainFromBackup composes two CRs into one backup chain — typically a daily FULL CR plus an hourly (or per-minute) DIFF CR that chains off it. Both CRs write to the same directory (<base>/<daily-cr-name>/); neo4j-admin --type=DIFF discovers the prior FULL and chains the diff off it.
# Daily FULL (Sundays at 02:00 — or any cadence)
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata: { name: inventory-daily }
spec:
instanceRef: my-cluster
database: inventory
schedule: "0 2 * * *"
storage: { type: s3, bucket: backups, path: prod, cloud: {…} }
options: { backupType: FULL }
retention: { maxCount: 30 }
---
# Hourly DIFF — chains into the daily's directory
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata: { name: inventory-hourly }
spec:
instanceRef: my-cluster
database: inventory
schedule: "30 * * * *" # offset 30 min to avoid racing the daily
chainFromBackup: inventory-daily # ← shared directory
storage: { type: s3, bucket: backups, path: prod, cloud: {…} }
options: { backupType: DIFF }
retention: { maxCount: 168 }
Constraints (validator-enforced; a target or storage mismatch → status.phase=Failed, while a parent that does not exist yet just leaves the backup Pending until it appears):
- The parent CR (
inventory-daily) must exist in the same namespace. - Both CRs must reference the same database (same
instanceRef+database). - Both CRs must use the same storage backend (type + bucket + path).
chainFromBackupcannot point to self.
Concurrent runs across chained CRs are blocked automatically. Each Job carries an app.kubernetes.io/part-of: <chain-root> label; the operator refuses to start a new backup Job while any other Job in the same chain is still active (status.active>0) — routes the new run to Pending and requeues. On PVC storage there's a second, run-time guard: each backup Job takes a lock file (<chain-root>/.chain.lock) on the shared directory before running neo4j-admin, so two Jobs that slip past the creation-time check (e.g. CronJob children firing in the same instant) serialize instead of corrupting the chain — the second waits up to 1 hour, then fails for its normal retry. Offsetting schedules (different minute on the hour) avoids the wait in practice.
Restore seeds from the latest successful artifact of the CR you reference — not the latest file in the shared directory. This selects your recovery point:
Neo4jRestore.spec.source.backupRef: inventory-hourly→ the newest differential. Neo4j applies the full + differential chain backward to it → you get the latest state.backupRef: inventory-daily→ the newest full → you roll back to the last full snapshot; the hourly diffs are not applied.
So reference the CR whose latest backup matches the recovery point you want — the DIFF CR for "latest", the FULL CR for "last full snapshot". Restoring via the parent FULL CR emits a RestoreFromChainParent Warning event naming the DIFF children, so a restore that intends "latest" but references the FULL CR isn't a silent surprise.
Feeding a cross-cluster replica: mode: replication-source¶
A backup chain can also be the source a replica on another Kubernetes cluster
pulls from. Set spec.mode: replication-source to declare that:
spec:
mode: replication-source
instanceRef: prod-cluster
database: inventory
schedule: "0 * * * *"
storage: { type: s3, bucket: prod-backups, path: inventory }
status:
replicationPullURI: s3://prod-backups/inventory/inventory-chain/
The mode changes no backup behaviour — same command, same artifacts. It
turns on validation for the constraints a replica depends on, and publishes
status.replicationPullURI for the downstream CR to consume:
| Rule | Why |
|---|---|
single-database scope (no allDatabases/shardedDatabase) |
the instance-wide artifact layout can't be consumed by a per-database pullURI |
no spec.retention |
pruning a differential's parent breaks the chain |
no competing Neo4jBackup on the same storage location |
a second writer interleaving artifacts breaks the chain (a CR joined via chainFromBackup is fine — that's the recommended shape) |
schedule required |
without one the chain never advances and the replica's lag grows without bound |
Bucket lifecycle rules will break the chain, and validation cannot catch it
For cloud storage the operator never prunes — retention is delegated to bucket lifecycle rules, which it can neither read nor validate. A lifecycle rule that expires objects in this directory silently breaks the differential chain and forces the replica to be rebuilt from scratch.
Exclude the replication-source path from lifecycle expiry. No operator-side rule protects you here.
See the Cross-Cluster Replication guide.
preferDiffAsParent (CalVer 2025.04+ only)¶
By default, differential backups use the most recent full backup as their parent. On CalVer 2025.04 and later, you can instruct the operator to use the most recent differential backup as the parent instead, creating a chain of incrementally smaller backups:
On Neo4j 5.26.x (semver) and CalVer versions before 2025.04 the operator refuses to build the backup command (--prefer-diff-as-parent requires CalVer 2025.04+), so the backup fails rather than silently ignoring the option. The same applies to the other version-gated options: parallelDownload and skipRecovery (CalVer 2025.11+) and remoteAddressResolution (CalVer 2025.09+).
The tempPath Option¶
For cloud storage destinations, neo4j-admin may use local disk space during streaming. Give it a dedicated staging volume to avoid filling the pod's working filesystem. The operator-managed way is options.tempStorage, which provisions a PVC and passes --temp-path for you:
If you mount your own volume instead, set options.tempPath to its absolute path (see Temporary Storage for Cloud Operations); the directory is created automatically if it does not exist.
Strongly recommended for all cloud storage backups.
Scheduled Backup Examples¶
Daily Scheduled Backup to S3¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata:
name: daily-backup
spec:
instanceRef: production-cluster
allDatabases: true
schedule: "0 2 * * *" # Daily at 2 AM UTC
storage:
type: s3
bucket: production-backups
path: daily
cloud:
provider: aws
credentialsSecretRef: aws-backup-creds
options:
compress: true
validate: true
tempStorage:
size: "50Gi"
retention:
maxAge: "168h"
maxCount: 7
deletePolicy: Delete
Schedule: Daily at 2 AM UTC — adjust as needed. The schedule must be plain 5-field UTC cron — TZ=/CRON_TZ= prefixes are rejected (Kubernetes refuses timezone-embedded CronJob schedules). Scheduled backup names are limited to 40 characters.
What
retentionactually does — read this before relying on it:
- Cloud storage (S3, GCS, Azure): the operator never deletes cloud objects. Retention cleanup is handled by your bucket's lifecycle rules, not the operator (which logs a notice to that effect). Configure lifecycle rules directly in your cloud provider:
- S3: S3 Lifecycle Rules
- GCS: Object Lifecycle Management
- Azure: Blob Lifecycle Management
- PVC storage: retention is enforced by a cleanup Job that runs only when the Neo4jBackup CR is deleted — it is not a continuous pruning loop. The Job prunes
*.backupfiles in this CR's chain directory only, oldest-first by mtime, and always keeps the newest artifact.maxAgeaccepts a singled/h/m/sunit ("30d","24h"; compound values like"1h30m"and"4w"are rejected).deletePolicyaccepts onlyDelete.- DIFF caveat: filenames don't encode FULL vs DIFF, so pruning can orphan diffs whose parent FULL ages out — the operator emits a
BackupRetentionCaveatwarning event when this applies. PreferbackupType: FULLon CRs that use retention.
Weekly Backup with Long Retention¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata:
name: weekly-backup
spec:
instanceRef: production-cluster
allDatabases: true
schedule: "0 1 * * 0" # Weekly on Sunday at 1 AM UTC
storage:
type: gcs
bucket: long-term-backups
path: weekly
cloud:
provider: gcp
identity:
provider: gcp
autoCreate:
annotations:
iam.gke.io/gcp-service-account: neo4j-backup@my-project.iam.gserviceaccount.com
options:
compress: true
validate: true
tempStorage:
size: "50Gi"
retention:
maxAge: "90d"
maxCount: 12
Best for: Enterprise compliance, long-term archival. For cloud storage the retention block is advisory — configure GCS Lifecycle Management to expire (or transition to archival storage classes) objects after 90 days. There is no operator-side archive action.
Suspended Backups¶
Temporarily pause a scheduled backup without deleting the resource. The operator propagates suspend: true to the CronJob's own spec.suspend, so Kubernetes stops firing scheduled Jobs entirely; flipping it back to false resumes the schedule. suspend: true also pauses a one-shot backup that hasn't run yet. (Separately: removing spec.schedule from a CR deletes its CronJob — the CR becomes a one-shot backup.)
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jBackup
metadata:
name: maintenance-backup
spec:
instanceRef: my-cluster
allDatabases: true
schedule: "0 3 * * *"
suspend: true # Suspends the backup schedule
storage:
type: s3
bucket: backups
path: maintenance
cloud:
provider: aws
credentialsSecretRef: aws-backup-creds
Restore Operations¶
How Restore Works¶
Restores run online, against the running DBMS: only the database being restored is unavailable, and every other database stays online. The Neo4j docs flag neo4j-admin database restore as unsafe on clusters ("not safe on a cluster since clusters have additional state that would be inconsistent with the restored database"), so a cluster always restores online; a standalone does too, except in the few cases where the server cannot fetch the seed itself:
| Target | Restore method | Backed by |
|---|---|---|
Neo4jEnterpriseCluster (standard DB) |
Cypher over Bolt — no Job | dbms.recreateDatabase(name, {seedURI}) if the DB exists, otherwise CREATE DATABASE name OPTIONS { seedURI } WAIT |
Neo4jEnterpriseStandalone |
The same Cypher over Bolt — no Job, no downtime for the other databases | as above |
Neo4jEnterpriseStandalone, offline cases (point-in-time that cannot run online, source.type: storage, cloud by pod identity without spec.podServiceAccountAnnotations, a run with no recorded artifact) |
Kubernetes Job; stops the instance | neo4j-admin database restore --from-path=<recorded artifact> followed by CREATE/START DATABASE |
Neo4jShardedDatabase (sharded) |
Rejected with actionable error | Use Neo4jShardedDatabase.spec.replaceExisting: true + force: true instead — see Property Sharding |
Restoring one database from an instance-wide backup (
allDatabases: true) works on both kinds: that backup stores one.backupartifact per database and records each instatus.history[].databaseArtifacts, and the operator seeds from the one forspec.database(orsource.sourceDatabase). If the backup's latest run did not record the database, the restore fails with a message naming the other ways in: adatabase-scoped backup, orsource.type: storagepointing at the exact.backupfile.
Online path (Cypher) — clusters and standalones; works with both cloud and PVC backups:
- Cloud-backed backup (S3 / GCS / Azure): the operator passes the exact
.backupfile URI of the latest successful run (s3://bucket/<path>/<backup-cr-name>/<dbname>-<timestamp>.backup) asseedURI. When that file is a differential, Neo4j applies the full + differential chain from the same directory automatically. The Neo4j server pods fetch the seed themselves, so they need the cloud credentials Secret in the cluster's or standalone'sspec.extraEnvFrom— the operator says exactly what is missing, or projects it for you when the CR carries the annotationneo4j.com/auto-inherit-seed-creds: "true"(one rolling restart). - PVC-backed backup: the operator starts a temporary proxy (
backup-seed-proxy-<restore-name>) that serves the backup PVC read-only over HTTP, and passes the artifact's URL asseedURI. A NetworkPolicy admits only the target's server pods (effective on enforcing CNIs), and the proxy is removed as soon as the restore reachesCompletedorFailed. No credentials required. - A PVC differential is merged first. Neo4j fetches the seed over HTTP as one file, and a differential needs the rest of its chain — with
backupType: AUTO(the default), every scheduled run after the first is one. So unless the backup recorded the artifact as a full backup (status.history[].artifactType), the proxy first merges the chain ending at it into a single full backup, with the target's own Neo4j image (neo4j-admin database aggregate-backupon 5.26,backup aggregateon CalVer), and serves that. Merging copies the chain to scratch space — anemptyDiron the node by default; setspec.options.tempStorageto at least the chain's size for a large store — and runs withspec.options.resources. It takes as long as copying the chain and replaying its transactions: the restore waits up tospec.timeout, 30 minutes by default while merging. If the chain cannot be merged — a full backup it needs was deleted, the scratch space ran out — the restore fails at once with neo4j-admin's reason. Restores of full backups skip all of this. - An existing database is recreated with
dbms.recreateDatabase, which keeps its users' privileges — this needs the explicit opt-inspec.options.replaceExisting: true, since it replaces the live contents. A new database is created withCREATE DATABASE … OPTIONS { seedURI } WAIT. - The restore is
Completedonce every allocation reportsonline, bounded byspec.timeout(default 5m) — raise it for multi-GB stores seeded from object storage.
S3-compatible endpoints (MinIO, on-prem object stores): the seed download runs inside the Neo4j server JVMs, which need the custom endpoint configuration in addition to the credentials. Add both to the cluster or standalone CR before restoring (or let the annotation above project them):
spec: env: - name: AWS_ENDPOINT_URL_S3 value: "http://minio.minio.svc:9000" # your S3-compatible endpoint - name: JAVA_TOOL_OPTIONS value: "-Daws.s3.forcePathStyle=true"(Triggers one rolling restart. Real AWS S3 — including IRSA/Workload Identity — needs none of this.)
After any online restore, confirm the database is online:
currentStatus: offlinewith a populatedstatusMessagemeans the seed failed — the message names the exact cause (e.g. an unreachable seed URI).
Offline path (Job) — standalones only, in the offline cases:
- The operator runs a Kubernetes Job that mounts the backup PVC (or streams from cloud storage) and runs
neo4j-admin database restore, then runsCREATE DATABASEorSTART DATABASEover Bolt. - It restores the artifact the backup recorded; from a
source.type: storagedirectory, the newest.backupfile in it.neo4j-adminapplies a differential's whole chain. - Use
stopCluster: true: the operator scales the instance down for the offline restore and back up afterwards. WithstopCluster: falseit refuses to start the Job while any server pod is running (it never writes into a live data volume), and the message says why the restore is offline.
No manual post-restore Cypher is required for either path — with one exception: an offline restore of a backup taken with includeMetadata (users/roles) writes a restore_metadata.cypher script under the data directory (/data/scripts/<dbname>/restore_metadata.cypher), and the operator does not run it. Re-apply users/roles manually:
kubectl exec <standalone>-0 -c neo4j -- bash -c \
'cypher-shell -u neo4j -p "$NEO4J_PASSWORD" -d system \
-f /data/scripts/<dbname>/restore_metadata.cypher'
Retrying a finished restore: Completed/Failed are terminal for the current spec generation. Edit the spec (any change that bumps the generation — typically source.backupRef) to issue a fresh attempt; changing backupRef re-resolves the pinned status.resolvedSource instead of reusing the old snapshot. Or delete and recreate the CR.
Simple Restore Examples¶
Restore from a Backup Reference¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRestore
metadata:
name: restore-from-backup
spec:
instanceRef: my-neo4j-cluster
database: neo4j
source:
type: backup
backupRef: daily-backup
options:
replaceExisting: true # required — "neo4j" already exists on the cluster
timeout: "30m" # online-convergence budget (default 5m)
This runs entirely over Cypher (no Job, stopCluster is ignored) and finishes with the database online, on a cluster or a standalone. When a standalone restores offline instead, the operator runs the restore Job and then automatically issues START DATABASE neo4j (or CREATE DATABASE neo4j for a new database). You do not need to run any Cypher manually.
Best for: Quick recovery from an existing Neo4jBackup resource.
Restore from a Storage Location¶
Finding backupPath: the operator records every run's directory and
artifact filename on the originating Neo4jBackup:
kubectl get neo4jbackup <name> -o jsonpath='{.status.history[0].backupsPath}' # directory
kubectl get neo4jbackup <name> -o jsonpath='{.status.history[0].artifactFilename}' # exact .backup file
(If the Backup CR still exists, source.type: backup with backupRef
resolves the path for you — prefer it.)
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRestore
metadata:
name: restore-from-s3
spec:
instanceRef: recovery-cluster
database: myapp-db
source:
type: storage
storage:
type: s3
bucket: backup-bucket
path: neo4j-backups/cluster
cloud:
provider: aws
credentialsSecretRef: aws-backup-creds
# For a CLUSTER target, backupPath must be the exact .backup FILE
# (CloudSeedProvider seeds a single DB from one file, not a directory).
# The chain root + filename are recorded on
# Neo4jBackup.status.history[*].{backupsPath,artifactFilename}; if it's a
# differential, Neo4j applies the full+diff chain from the same directory.
# Standalone targets may pass just the directory — a storage-path
# restore runs offline there, and the Job picks the latest file via
# `ls … | tail -1`.
backupPath: daily-backup/myapp-db-2025-06-01T02-00-00.backup
options:
replaceExisting: true
tempStorage:
size: "50Gi"
stopCluster: true # only for a standalone's offline cases; ignored by online restores
Best for: Cross-cluster recovery, disaster recovery from a known directory in storage (no Neo4jBackup CR available in this namespace).
Restoring after the
Neo4jBackupCR is deleted. Asource.type: backuprestore pins the backup's storage location onto its ownstatus.resolvedSourcethe first time it resolves the reference. From that point on the restore reads the snapshot, so deleting theNeo4jBackupCR mid-restore (or while it retries) does not break it — the operator already knows where the artifacts are.If you create a new restore after the Backup CR is gone, there's nothing to resolve, so
source.type: backupfails with an error pointing you here. Restore directly from the artifacts withsource.type: storage(above): setsource.storageto the backup's location and pointbackupPathat the exact.backupfile (cluster) or the directory (standalone). You do not need to — and should not — re-create the Backup CR: re-creating it triggers a fresh backup Job that writes into the same chain directory, which can shadow the artifact you wanted to restore.⚠️ Restore is destructive and overwrites in place. With
options.replaceExisting: truethe target database's current data is replaced by the backup. Re-running a restore — including after re-creating a deleted Backup CR — re-seeds and overwrites again. Treat every restore as a destructive operation against the named database.Which run a restore picks: both cluster and standalone restores read the latest successful artifact of the referenced
Neo4jBackupCR, by its recorded filename (an offline standalone restore falls back to the newest<database>-*.backupin that CR's directory only for a backup that recorded none). In a FULL+DIFF chain, reference the DIFF CR for the latest state or the FULL CR to roll back to the last full snapshot — restoring via the FULL CR does not apply the newer diffs (and emits aRestoreFromChainParentwarning). To pin to an arbitrary earlier run, setsource.type: storagewithbackupPathpointing at the exact.backupfile, or keep a point-in-time snapshot of the directory (cloud lifecycle rules / versioning). Withsource.pointInTime, a restore picks the run that holds that moment instead: the earliest that started at or after it (Point-in-Time Recovery).
Restoring under a different name¶
Set spec.database to the new name. From a backup of one database that is all it takes — the operator knows which file to read. From a backup that holds several (an allDatabases backup, or a source.type: storage directory), also say which database to take:
spec:
database: customers-copy # created by the restore
source:
type: backup
backupRef: nightly-all # an allDatabases backup
sourceDatabase: customers # the database inside it
This works on clusters and standalones alike. neo4j-admin restores under a new name only from a single .backup file, so from a cloud directory (source.type: storage without the filename) a rename is refused with a message; point source.backupPath at the exact file instead.
Restore to a Standalone Instance¶
instanceRef can reference a Neo4jEnterpriseStandalone as well as a
Neo4jEnterpriseCluster — the operator auto-detects the kind. A standalone
restores online, exactly like a cluster: dbms.recreateDatabase over an
existing database, CREATE DATABASE … OPTIONS { seedURI } for a new one. Only
the database being restored is unavailable; the instance keeps running and its
other databases stay online. stopCluster is ignored (an event says so).
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRestore
metadata:
name: restore-to-standalone
spec:
instanceRef: my-standalone # References Neo4jEnterpriseStandalone
database: neo4j
source:
type: backup
backupRef: standalone-backup
options:
replaceExisting: true # confirms overwriting the existing database
Online, the standalone's own server fetches the seed, so from cloud storage it
needs the credentials and (for MinIO) the endpoint on its pods — the same
spec.extraEnvFrom / spec.env, or the neo4j.com/auto-inherit-seed-creds:
"true" annotation (one restart), as a cluster.
When a standalone restores offline instead. Some seeds the server cannot
fetch on its own. Those restores take the neo4j-admin Job, which stops the
instance — every database is offline until it is back:
| Case | Why it cannot run online |
|---|---|
Point-in-time that cannot run online: Neo4j 5.26, a PVC backup, a database that exists, or source.type: pitr |
only neo4j-admin restores those to a point in time |
source.type: storage |
the path may be a directory or part of a backup chain; only neo4j-admin resolves those |
Cloud storage with no credentialsSecretRef (Workload Identity, node IAM), on a standalone without spec.podServiceAccountAnnotations |
its pod has no workload identity; the restore Job's ServiceAccount does. Set the field on the standalone to restore online instead |
source.type: backup whose run recorded no artifact (a run from an older operator, or one whose log could not be read) |
only neo4j-admin finds the file in the backup directory |
These need stopCluster: true — with false the operator refuses while pods
are running, and the message names the case. The Job path then:
- Scales the standalone StatefulSet to 0.
- Runs a restore Job that mounts the data PVC and the backup source and runs
neo4j-admin database restore. From a chain directory it picks the latest.backupfile automatically; from a read-only backup PVC it extracts via a temp dir (--temp-path) since the source mount is RO. - Scales the StatefulSet back up, and once the instance is
Readybrings the database online (CREATE DATABASE/START DATABASE).
Pre/post restore hooks run on both paths — see Restore with Hooks.
spec.options.replaceExisting: trueis the flag that confirms overwriting an existing database — on both cluster and standalone targets.
Point-in-Time Recovery (PITR)¶
PITR restores a database as it was at a given moment. It works two ways.
Online, from a backup in cloud storage¶
On a cluster or a standalone running CalVer, restore a Neo4jBackup in cloud
storage into a new database with source.pointInTime:
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRestore
metadata:
name: orders-before-incident
spec:
instanceRef: production-cluster # a cluster or a standalone
database: orders-asof-1229 # a database that does not exist yet
source:
type: backup
backupRef: orders-backup # backs up to S3, GCS or Azure
pointInTime: "2026-10-06T12:29:00Z"
The operator picks the backup run that holds the point in time: the earliest
successful run that started at or after it (status.resolvedSource.backupStartedAt).
It creates the database from that run with Neo4j's seedRestoreUntil, so Neo4j
replays the run's chain and stops at the point in time. Nothing else is stopped,
and every other database stays online.
It runs online only when all of these hold:
- The target runs CalVer. Neo4j 5.26 has no
seedRestoreUntil. - The backup is in cloud storage. A PVC backup reaches Neo4j over HTTP through the seed proxy, and Neo4j applies a point in time only to cloud seeds.
- The database does not exist yet.
seedRestoreUntilis an option ofCREATE DATABASE; recreating a database cannot stop at a point in time. Restore under a new name, compare, and copy back what was lost. - A run started at or after the point in time. Otherwise no backup is known to hold every transaction up to it. Take a backup, or pick an earlier point.
When one does not hold, a standalone restores offline instead (below; it needs
stopCluster: true). A cluster cannot, so the restore fails and names the
reason. The decision is made once, when the restore starts, and the restore
records it in the neo4j.com/point-in-time-path annotation.
Offline, with neo4j-admin (standalones)¶
The offline path runs neo4j-admin database restore --restore-until="<pointInTime>".
It stops the standalone, so every database on it is offline until it is back.
It covers what the online path cannot: Neo4j 5.26, PVC backups, a database that
exists, and source.type: pitr.
With source.type: pitr the base backup comes from source.pitr.baseBackup,
and the transaction logs it replays come from that backup chain.
source.pitr.logStorage is accepted by the CRD but the restore command does not
read it (a pvc log storage is only mounted at /transaction-logs), so the
examples below omit it.
Note:
source.type: pitrapplies only to aNeo4jEnterpriseStandalonetarget; the operator rejects it on a cluster. On a cluster, use the online path above, or aNeo4jDatabasewithspec.seedConfig.restoreUntil.
PITR Configuration¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRestore
metadata:
name: pitr-restore
spec:
instanceRef: recovery-standalone # Neo4jEnterpriseStandalone (PITR is standalone-only)
database: production-db
source:
type: pitr
pointInTime: "2025-01-04T12:30:00Z"
pitr:
baseBackup:
type: backup
backupRef: daily-backup
options:
replaceExisting: true
stopCluster: true
Best for: Compliance requirements, precise recovery to a moment before a bad event.
PITR with Storage-Based Base Backup¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRestore
metadata:
name: pitr-storage-restore
spec:
instanceRef: dr-standalone # Neo4jEnterpriseStandalone (PITR is standalone-only)
database: critical-app
source:
type: pitr
pointInTime: "2025-01-04T14:45:30Z"
pitr:
baseBackup:
type: storage
storage:
type: gcs
bucket: base-backups
path: production/base-backup-20250104
cloud:
provider: gcp
credentialsSecretRef: gcs-backup-creds
# Relative to storage.path: the operator reads
# gs://base-backups/production/base-backup-20250104/critical-app-2025-01-04T02-00-00.backup
backupPath: critical-app-2025-01-04T02-00-00.backup
options:
replaceExisting: true
stopCluster: true
Restore with Hooks¶
Pre and post-restore hooks execute custom operations at key points in the restore lifecycle.
Hooks run on every target, online and offline. The ordering is:
- preRestore hooks — run before anything is restored (and, offline, before the instance is stopped), so Cypher hooks like
CALL db.checkpoint()hit a live Bolt endpoint. An all-databases restore runs them once, before its first database. - The restore: online,
dbms.recreateDatabase/CREATE DATABASE … OPTIONS { seedURI }; offline, instance scale-down (stopCluster: true), restore Job, scale-up, then the automaticCREATE DATABASE/START DATABASE. - postRestore hooks — run once the restored database is online, so Cypher hooks like
CALL db.awaitIndexes()target a live database. A failing post-restore hook fails the restore.
Restore with Cypher Hooks¶
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRestore
metadata:
name: restore-with-hooks
spec:
instanceRef: my-standalone
database: myapp
source:
type: backup
backupRef: production-backup
options:
replaceExisting: true # confirms overwriting the existing database
preRestore:
cypherStatements:
- "CALL db.checkpoint()"
postRestore:
cypherStatements:
- "CALL db.awaitIndexes()"
- "CALL dbms.security.clearAuthCache()"
Restore with Job Hooks¶
Hook Jobs run in their own pods — anything that talks to Neo4j (e.g. cypher-shell) must address the instance's service explicitly (-a neo4j://<standalone>-client:7687), or it will try to connect to localhost inside the hook pod. (<standalone>-service also resolves but is deprecated — use -client.)
apiVersion: neo4j.neo4j.com/v1beta1
kind: Neo4jRestore
metadata:
name: restore-with-job-hooks
spec:
instanceRef: staging-standalone
database: app-data
source:
type: backup
backupRef: staging-backup
options:
preRestore:
job:
template:
container:
image: my-registry/data-prep:latest
command: ["/bin/sh"]
args: ["-c", "/scripts/pre-restore.sh"]
env:
- name: INSTANCE_NAME
value: staging-standalone
- name: DATABASE_NAME
value: app-data
timeout: "10m"
postRestore:
job:
template:
container:
image: my-registry/data-validator:latest
command: ["/bin/sh"]
args: ["-c", "/scripts/validate-restore.sh"]
env:
- name: NEO4J_URI
value: "neo4j://staging-standalone-client:7687"
- name: NEO4J_PASSWORD
valueFrom:
secretKeyRef:
name: staging-admin-secret
key: password
timeout: "15m"
Monitoring Backup and Restore Operations¶
Checking Backup Status¶
# List all backups
kubectl get neo4jbackups
# Get detailed backup status
kubectl describe neo4jbackup daily-backup
# View backup history — for SCHEDULED backups this is the authoritative record:
# they stay in phase "Scheduled" (never "Completed") and don't set
# status.lastSuccessTime; each run appends here instead.
kubectl get neo4jbackup daily-backup -o jsonpath='{.status.history}'
# Check backup job logs (one-shot: <name>-backup; scheduled runs: use the
# history entry's runID, which is the Job name)
kubectl logs job/daily-backup-backup
Checking Restore Status¶
# List all restores
kubectl get neo4jrestores
# Get detailed restore status — the events name the path (online or offline)
kubectl describe neo4jrestore restore-operation
# Offline standalone restores only: the restore Job's log
kubectl logs job/restore-operation-restore
# Monitor restore progress
kubectl get neo4jrestore restore-operation -w
Backup and Restore Events¶
# View events for backup operations
kubectl get events --field-selector involvedObject.name=daily-backup
# View events for restore operations
kubectl get events --field-selector involvedObject.name=restore-operation
Operational Notes¶
- Enable
validate: trueto runneo4j-admin backup validateafter each backup, so recoverability issues are surfaced at backup time (onstatus.history[].validation) rather than discovered at restore time. Requires a CalVer (2025.x+) Neo4j image — theneo4j-admin backup validatesubcommand does not exist on 5.26, so on a 5.26 target the option has no effect (status.history[].validationstays empty) and the operator emits a one-timeBackupValidateUnsupportedwarning event. The backup itself still succeeds either way. - For cloud destinations, set
tempStorage(PVC for staging) on large databases — without itneo4j-adminbuffers in the Pod's filesystem. - The operator doesn't manage cloud object expiry — configure bucket lifecycle rules to delete old backups. With split archives, filter those rules by prefix, not by a
.backupsuffix. - Restore is non-trivial post-incident; rehearse it against a staging cluster at least once.
- Prefer Workload Identity (IRSA / GKE WI / Azure WI) over static credentials. Rotate
credentialsSecretRefSecrets if you do use them. - IAM permissions needed:
PutObject,GetObject,ListBucket,DeleteObjecton the backup bucket prefix.
Advanced Configuration¶
Cloud Storage Authentication (Full Reference)¶
The cloud block inside storage supports the following fields:
storage:
type: s3 | gcs | azure
bucket: <bucket-or-container-name>
path: <prefix-within-bucket>
cloud:
provider: aws | gcp | azure
# Path 1: Explicit credentials
credentialsSecretRef: <secret-name>
# Secret keys by provider:
# AWS: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
# GCP: GOOGLE_APPLICATION_CREDENTIALS_JSON (must be this exact key)
# Azure: AZURE_STORAGE_ACCOUNT, AZURE_STORAGE_KEY
# Path 2: Workload Identity annotations on neo4j-backup-sa
identity:
provider: aws | gcp | azure # required; match the storage provider
autoCreate:
annotations:
<annotation-key>: <annotation-value>
# Examples:
# AWS IRSA: eks.amazonaws.com/role-arn: arn:aws:iam::...
# GKE WI: iam.gke.io/gcp-service-account: sa@project.iam.gserviceaccount.com
# Azure WI: azure.workload.identity/client-id: <client-id>
Only one of credentialsSecretRef or identity should be specified at a time.
Temporary Storage for Cloud Operations¶
Cloud backups and restores require local staging space for neo4j-admin. Without explicit temp storage, staging uses the container's ephemeral disk, which may be too small for large databases.
The tempStorage field tells the operator to create a PVC for staging automatically:
spec:
options:
tempStorage:
size: "50Gi" # should be >= expected backup size
storageClassName: gp3 # optional, uses cluster default if omitted
The operator:
- Creates a PVC named
{backup-name}-temp-staging(or{restore-name}-temp-staging) - Sets the CR as owner — the PVC is garbage-collected when the backup/restore CR is deleted
- Mounts the PVC at
/tmp/neo4j-stagingin the Job pod - Passes
--temp-path=/tmp/neo4j-stagingtoneo4j-admin
If you prefer to manage the PVC yourself, use tempPath instead (points to any path you've mounted via additionalArgs or other means).
Backup Options Reference¶
options:
# Backup type: AUTO (default — FULL first run, DIFF after), FULL, or DIFF
backupType: AUTO
# For DIFF backups on CalVer 2025.04+: use latest diff instead of latest full as parent
preferDiffAsParent: false
# Compress backup data (recommended)
compress: true
# Validate backup recoverability after creation by running
# `neo4j-admin backup validate`. Failures are recorded on
# status.history[].validation but do NOT fail the Job.
# Requires a CalVer (2025.x+) image — no effect on 5.26 (subcommand
# does not exist); the operator emits a BackupValidateUnsupported warning.
validate: true
# Operator-managed staging PVC for cloud operations (recommended for large databases)
tempStorage:
size: "50Gi"
storageClassName: gp3 # optional
# Manual temp path (alternative to tempStorage — you must mount the volume yourself)
# tempPath: /my/mounted/volume
# Include users/roles metadata in backup (Neo4j 5.26+)
# Values: all (default), none, users, roles
includeMetadata: all
# Multi-threaded transaction application during backup
parallelRecovery: false
# Preserve failed backup artifacts for debugging
keepFailed: false
# Pass additional flags directly to neo4j-admin database backup
additionalArgs:
- "--verbose"
Split backup archives¶
Neo4j 2026.09+. By default an artifact is one file. For a database whose backup is larger than the target storage accepts as a single object, split it into parts:
- Size: a Kubernetes quantity of at least
1Gi, Neo4j's minimum part size. Note that1Gis 10⁹ bytes, which is below the minimum: the backup goesInvalidwith a message that says so. - Version: on a target older than 2026.09 the backup is refused with a message naming the field and the image.
- Files written: each artifact becomes
<db>-<timestamp>.backup, a small metadata file, plus<db>-<timestamp>.backup.1,.backup.2, … holding the data. That layout applies whenever the option is set, so even a small differential is a metadata file plus.backup.1. - Nothing else changes. Restores and replicas reference the
.backupfile and Neo4j reads the parts beside it, for every restore and replica path the operator drives: - offline standalone restore (
neo4j-admin); - online restore (cluster or standalone) from cloud storage or a PVC;
- a
Neo4jReplicaDatabaseseeding from and pulling a split chain. - Keep the parts together:
- PVC retention deletes an artifact's parts with it.
- On cloud storage, filter bucket lifecycle rules by prefix, never by a
.backupsuffix: a suffix filter expires the metadata file and strands the parts. neo4j-admin database upload(to Aura) does not accept split archives.
Custom Backup Arguments¶
Pass flags directly to neo4j-admin database backup:
Namespace scoping¶
A Neo4jBackup backs up a deployment in its own namespace. instanceRef (and shardedDatabase) resolve within the backup CR's namespace, and the API has no namespace field to point elsewhere. This keeps each backup's blast radius inside a single namespace — the same boundary the operator enforces for users and roles. To back up a cluster that lives in another namespace, create the Neo4jBackup in that namespace.
Troubleshooting Quick Reference¶
Quick Fixes¶
| Problem | Quick Check | Solution |
|---|---|---|
| Backup Failed | kubectl describe neo4jbackup <name> |
Check events and conditions |
| Permission Denied on cloud storage | kubectl logs job/<backup-name>-backup |
Verify credentialsSecretRef or Workload Identity setup |
| Version Error | Check cluster Neo4j version | Ensure 5.26.0+ or 2025.01.0+ |
| Pod filesystem full | Check df -h in backup pod |
Set tempPath to a larger volume or use a PVC |
Backup job fails with path does not exist |
Check tempPath |
Set a valid tempPath or ensure the path is auto-created |
Backup fails with --prefer-diff-as-parent requires CalVer 2025.04+ |
Check Neo4j version | Remove preferDiffAsParent, or run CalVer 2025.04+ |
| Database not online after restore | Check restore status | Should be automatic — check operator logs for Bolt errors |
Detailed Troubleshooting¶
For comprehensive troubleshooting, diagnostics, and advanced problem-solving, see the Complete Troubleshooting Guide.
Additional Resources¶
API Documentation¶
- Neo4jBackup API Reference — Complete field specifications and options
- Neo4jRestore API Reference — Detailed restore configuration reference
Examples and Templates¶
- Working Examples — Copy-paste ready YAML files
- Getting Started Guide — Deploy your first cluster
- Installation Guide — Install the operator
Advanced Topics¶
- Troubleshooting Guide — Comprehensive problem-solving
- Security Best Practices — Secure your backup operations
- Performance Tuning — Optimize backup/restore performance
Community and Support¶
- GitHub Issues — Report bugs and request features
- Neo4j Community — Get help from the community
- Neo4j Documentation — Official Neo4j documentation