Skip to content

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:

kubectl get neo4jenterprisecluster minimal-cluster -o jsonpath='{.status.phase}{"\n"}'
# → Ready

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:

kubectl get secret neo4j-admin-secret

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 a schedule: field it becomes a recurring CronJob that stays in phase Scheduled — check status.history[] for each run instead.

The PVC (first-backup-pvc) and its backups survive even if you delete the Neo4jBackup CR — reclaim the space with kubectl 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 in spec.database (or source.sourceDatabase, to restore it under another name); the operator reads its artifact from the backup's recorded per-database map. To restore them all, use allDatabases: 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)

spec:
  instanceRef: my-neo4j
  allDatabases: true
  storage: { type: s3, bucket: backups }

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.

ℹ️ allDatabases and property-sharded databases — one backup, two restore paths. An instance-wide backup catalogues each property-sharded family's per-shard .backup files in status.history[].shardedFamilies (the family names are also listed in shardedDatabasesExcluded, with a BackupShardedDatabasesExcluded event). So one allDatabases backup 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-topology CREATE clauses only Neo4jShardedDatabase emits), so to recover a sharded family you re-apply its Neo4jShardedDatabase CR with spec.seedBackupRef pointing at that same backup (add spec.seedSourceDatabase if restoring under a different name). The all-databases restore re-warns (RestoreShardedDatabasesNotCovered) naming each family and the seedBackupRef to use — see Property Sharding. Recovering a mixed cluster is therefore an all-databases restore plus one Neo4jShardedDatabase re-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 own shardedDatabase:-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:

kubectl get neo4jbackup daily-backup -o jsonpath='{.status.history[*].runID}'

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 no spec.cloud.region field. For credentialsSecretRef backups 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 (and neo4j-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 different autoCreate.annotations, the last one reconciled wins and the other's cloud access breaks; the operator flags this with a ServiceAccountAnnotationConflict Warning 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 endpointURL to https://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 — use additionalArgs to pass --ssl-certificate-authorities if 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 endpointURL and forcePathStyle on the backup's cloud block; the operator injects AWS_ENDPOINT_URL_S3 and 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 via backupRef, the operator auto-projects AWS_ENDPOINT_URL_S3 (and the path-style JVM opt) onto the cluster's or standalone's spec.env when you opt in with the neo4j.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 a spec.env entry 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/Neo4jShardedDatabase seedURI seeding from a custom endpoint is not auto-projected — provide the endpoint via the cluster's spec.env or 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 Neo4jBackup CR. Reclaim backup storage explicitly with kubectl 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).
  • chainFromBackup cannot 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:

options:
  backupType: DIFF
  preferDiffAsParent: true  # CalVer 2025.04+ only
  tempStorage:
    size: "50Gi"

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:

options:
  tempStorage:
    size: "50Gi"

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 retention actually 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 *.backup files in this CR's chain directory only, oldest-first by mtime, and always keeps the newest artifact. maxAge accepts a single d/h/m/s unit ("30d", "24h"; compound values like "1h30m" and "4w" are rejected). deletePolicy accepts only Delete.
  • DIFF caveat: filenames don't encode FULL vs DIFF, so pruning can orphan diffs whose parent FULL ages out — the operator emits a BackupRetentionCaveat warning event when this applies. Prefer backupType: FULL on 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 .backup artifact per database and records each in status.history[].databaseArtifacts, and the operator seeds from the one for spec.database (or source.sourceDatabase). If the backup's latest run did not record the database, the restore fails with a message naming the other ways in: a database-scoped backup, or source.type: storage pointing at the exact .backup file.

Online path (Cypher) — clusters and standalones; works with both cloud and PVC backups:

  • Cloud-backed backup (S3 / GCS / Azure): the operator passes the exact .backup file URI of the latest successful run (s3://bucket/<path>/<backup-cr-name>/<dbname>-<timestamp>.backup) as seedURI. 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's spec.extraEnvFrom — the operator says exactly what is missing, or projects it for you when the CR carries the annotation neo4j.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 as seedURI. A NetworkPolicy admits only the target's server pods (effective on enforcing CNIs), and the proxy is removed as soon as the restore reaches Completed or Failed. 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-backup on 5.26, backup aggregate on CalVer), and serves that. Merging copies the chain to scratch space — an emptyDir on the node by default; set spec.options.tempStorage to at least the chain's size for a large store — and runs with spec.options.resources. It takes as long as copying the chain and replaying its transactions: the restore waits up to spec.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-in spec.options.replaceExisting: true, since it replaces the live contents. A new database is created with CREATE DATABASE … OPTIONS { seedURI } WAIT.
  • The restore is Completed once every allocation reports online, bounded by spec.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:

SHOW DATABASE <name> YIELD name, currentStatus, statusMessage;

currentStatus: offline with a populated statusMessage means 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 runs CREATE DATABASE or START DATABASE over Bolt.
  • It restores the artifact the backup recorded; from a source.type: storage directory, the newest .backup file in it. neo4j-admin applies a differential's whole chain.
  • Use stopCluster: true: the operator scales the instance down for the offline restore and back up afterwards. With stopCluster: false it 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 Neo4jBackup CR is deleted. A source.type: backup restore pins the backup's storage location onto its own status.resolvedSource the first time it resolves the reference. From that point on the restore reads the snapshot, so deleting the Neo4jBackup CR 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: backup fails with an error pointing you here. Restore directly from the artifacts with source.type: storage (above): set source.storage to the backup's location and point backupPath at the exact .backup file (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: true the 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 Neo4jBackup CR, by its recorded filename (an offline standalone restore falls back to the newest <database>-*.backup in 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 a RestoreFromChainParent warning). To pin to an arbitrary earlier run, set source.type: storage with backupPath pointing at the exact .backup file, or keep a point-in-time snapshot of the directory (cloud lifecycle rules / versioning). With source.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:

  1. Scales the standalone StatefulSet to 0.
  2. 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 .backup file automatically; from a read-only backup PVC it extracts via a temp dir (--temp-path) since the source mount is RO.
  3. Scales the StatefulSet back up, and once the instance is Ready brings the database online (CREATE DATABASE/START DATABASE).

Pre/post restore hooks run on both paths — see Restore with Hooks.

spec.options.replaceExisting: true is 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. seedRestoreUntil is an option of CREATE 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: pitr applies only to a Neo4jEnterpriseStandalone target; the operator rejects it on a cluster. On a cluster, use the online path above, or a Neo4jDatabase with spec.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:

  1. 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.
  2. The restore: online, dbms.recreateDatabase / CREATE DATABASE … OPTIONS { seedURI }; offline, instance scale-down (stopCluster: true), restore Job, scale-up, then the automatic CREATE DATABASE / START DATABASE.
  3. 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: true to run neo4j-admin backup validate after each backup, so recoverability issues are surfaced at backup time (on status.history[].validation) rather than discovered at restore time. Requires a CalVer (2025.x+) Neo4j image — the neo4j-admin backup validate subcommand does not exist on 5.26, so on a 5.26 target the option has no effect (status.history[].validation stays empty) and the operator emits a one-time BackupValidateUnsupported warning event. The backup itself still succeeds either way.
  • For cloud destinations, set tempStorage (PVC for staging) on large databases — without it neo4j-admin buffers 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 .backup suffix.
  • 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 credentialsSecretRef Secrets if you do use them.
  • IAM permissions needed: PutObject, GetObject, ListBucket, DeleteObject on 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:

  1. Creates a PVC named {backup-name}-temp-staging (or {restore-name}-temp-staging)
  2. Sets the CR as owner — the PVC is garbage-collected when the backup/restore CR is deleted
  3. Mounts the PVC at /tmp/neo4j-staging in the Job pod
  4. Passes --temp-path=/tmp/neo4j-staging to neo4j-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:

spec:
  options:
    splitArchivePartSize: 500Gi   # each part at most 500GiB
  • Size: a Kubernetes quantity of at least 1Gi, Neo4j's minimum part size. Note that 1G is 10⁹ bytes, which is below the minimum: the backup goes Invalid with 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 .backup file 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 Neo4jReplicaDatabase seeding 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 .backup suffix: 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:

spec:
  options:
    additionalArgs:
      - "--verbose"
      - "--parallel-recovery"

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

Examples and Templates

Advanced Topics

Community and Support