Every Neo4j cluster we are asked to review has backups. Roughly half of them have backups that nobody has ever restored. That gap is where outages turn into incidents: the artefacts exist, the cron job is green, and then someone discovers the retention window is 24 hours, or the backup contains the corrupted data, or the restore takes eleven hours on a database the business expects back in one.
This tutorial is the practical version of the thing everyone skips. You will take a full backup, chain differential backups onto it, aggregate the chain, inspect what you actually hold, restore into a scratch database, and roll it forward to a chosen point in time. Then you will wrap it in a drill you can run quarterly.
Commands below use the modern neo4j-admin database backup family (Neo4j 5.x and the 2025.x/2026.x CalVer line). If you are still on 4.4 you are past end of support and the syntax differs — migrate first.
The three things a backup strategy has to answer
Before any command, write down three numbers and get the business to agree to them:
- RPO (recovery point objective) — how much data may we lose? This sets backup frequency and whether you need transaction-log shipping.
- RTO (recovery time objective) — how long may recovery take? This sets where artefacts live (object storage in another region is slow to pull) and how big a chain you allow before aggregating.
- Retention — how far back must we be able to go? Logical corruption discovered three weeks late is the case that kills 7-day retention.
Online backup requires Neo4j Enterprise. On Community the equivalent is neo4j-admin database dump with the database offline (or against a stopped copy), which usually means an RPO measured in hours. Say that out loud to stakeholders rather than discovering it during an incident.
Step 1 — enable the backup listener
On each server you intend to back up from, in neo4j.conf:
server.backup.enabled=true
server.backup.listen_address=0.0.0.0:6362
Back up from a secondary/read replica where you have one: the copy job competes for page cache and disk throughput with your query load.
Step 2 — the full backup
neo4j-admin database backup neo4j \
--to-path=/backups/neo4j \
--type=full \
--from=neo4j-02.internal:6362 \
--compress=true \
--verbose
The artefact is a single file named after the database, its ID, and a timestamp, for example neo4j-2026-02-11T02-00-05.backup. Two habits worth forming immediately:
- One directory per database. Mixed directories make aggregation and retention scripts fragile.
- Never write straight to the only copy. Write locally, then push to object storage with a lifecycle policy. A backup that lives only on the machine you are trying to recover is decoration.
Step 3 — differential backups and the chain
A differential backup copies only what changed since the last backup in the chain, so nightly runs finish in minutes instead of hours:
neo4j-admin database backup neo4j \
--to-path=/backups/neo4j \
--type=diff \
--from=neo4j-02.internal:6362
A sensible default schedule for a mid-sized transactional graph:
| Cadence | Type | Why |
|---|---|---|
| Weekly (Sunday) | full | Bounds chain length and restore time |
| Nightly | diff | Cheap, keeps RPO at one day |
| Continuous | transaction log retention | Enables point-in-time recovery between backups |
Chains cost you at restore time: restoring a full plus twenty differentials means replaying twenty artefacts. Keep the chain short, or aggregate.
Step 4 — aggregate the chain
aggregate collapses a full backup and its differentials into a single new full artefact, without touching the live database:
neo4j-admin database aggregate-backup \
--from-path=/backups/neo4j \
neo4j
Run this on the backup host (a small worker box, not the database server) after the weekly full, then apply your retention policy to the superseded files. Check neo4j-admin database aggregate-backup --help on your exact version — the CalVer line has been tidying these commands, and the flag names are the part most likely to have drifted since this was written.
Step 5 — inspect what you hold
The cheapest reassurance available:
neo4j-admin database inspect --from-path=/backups/neo4j neo4j
This lists the artefacts in the chain, their type, and the transaction range each one covers. Two failure modes it catches early:
- A broken chain — a differential whose parent was deleted by an over-eager retention job. Everything after the gap is unusable.
- A stalled transaction range — the last transaction ID has not moved for days, which usually means you are backing up a database that is no longer receiving writes (wrong instance, wrong database name after a rename).
Alert on both. A nightly job that pipes inspect output into your monitoring system is a couple of hours of work and the highest-value monitoring you will add this quarter.
Step 6 — restore into a scratch database
Never restore over the database you are investigating; restore beside it. Restoring requires the target database to not exist or to be dropped first, and in a cluster you restore on each server that will host it.
# stop the target database (leave the rest of the DBMS running)
cypher-shell -u neo4j -p "$NEO4J_PASSWORD" -d system \
"STOP DATABASE recovery_test;"
neo4j-admin database restore recovery_test \
--from-path=/backups/neo4j \
--overwrite-destination=true \
--verbose
cypher-shell -u neo4j -p "$NEO4J_PASSWORD" -d system \
"CREATE DATABASE recovery_test IF NOT EXISTS;"
On a cluster, CREATE DATABASE ... TOPOLOGY 3 PRIMARIES after restoring the store on each primary. Time this step with a stopwatch every drill — restore duration is the number your RTO promise actually depends on, and it grows with the store.
Step 7 — point-in-time recovery
Backups alone give you "last night at 02:00". PITR gives you "11:47, thirty seconds before the bad migration ran". It works by restoring the nearest backup and replaying retained transaction logs up to a boundary.
First, make sure the logs exist. In neo4j.conf:
# keep enough log history to cover your worst-case detection window
db.tx_log.rotation.retention_policy=7 days
The default retention is far shorter than most teams assume, and logs are pruned silently. If a migration ran on Friday and someone notices on Monday, a 2-day policy has already discarded your only route back.
Then restore with a boundary:
neo4j-admin database restore recovery_test \
--from-path=/backups/neo4j \
--restore-until="2026-02-11 11:47:00" \
--overwrite-destination=true
You can also cut at a transaction ID, which is more precise when you know the offending transaction:
--restore-until=12873441
Finding that ID is the real work. If you run CDC into Kafka, the change stream carries transaction identifiers and timestamps and makes the search almost trivial — one of several reasons we like CDC on write-heavy systems. Otherwise, query.log with the offending statement's timestamp gets you close enough to bracket the cut.
Step 8 — verify the restore, don't assume it
A restore that starts is not a restore that worked. Run a verification query set against the scratch database and compare it with known-good expectations:
// 1. Does the schema exist?
SHOW INDEXES YIELD name, type, state
WHERE state <> 'ONLINE'
RETURN count(*) AS notOnline;
// 2. Do the counts look right?
MATCH (n) RETURN labels(n) AS label, count(*) AS nodes ORDER BY nodes DESC;
// 3. Is the newest data present, and how fresh is it?
MATCH (o:Order)
RETURN max(o.createdAt) AS newestOrder;
// 4. Spot-check a business invariant
MATCH (o:Order)
WHERE NOT (o)-[:PLACED_BY]->(:Customer)
RETURN count(o) AS orphanOrders;
Store the expected shape of these results in the repo next to the drill script. "Node counts within 2% of production and zero orphan orders" is a pass criterion; "it seemed fine" is not.
Also confirm indexes are populated. A store restore brings indexes with it, but a database rebuilt some other way may leave you POPULATING and your first production query behind a full scan. SHOW INDEXES at the top of the verification set covers this.
Aura and managed instances
On Aura you do not run neo4j-admin. Aura takes daily snapshots and lets you trigger on-demand snapshots via the console or API, with paid tiers supporting longer retention. The important differences:
- Snapshots are per-instance and restore into an instance — cloning to a new instance is the safe way to inspect one without disturbing production.
- Exports are your portability guarantee. Take a periodic snapshot export to your own cloud storage so your recovery story does not depend entirely on the provider's console being available and your account being in good standing.
- Drills still apply. "Managed" changes who runs the command, not whether anyone has verified that the data comes back.
The quarterly drill runbook
Thirty to sixty minutes, one engineer, calendar invite, rotate who runs it:
- Pick a random artefact from the last 30 days — not the newest one.
- Run
inspect; confirm the chain is intact. - Restore into
recovery_teston a non-production host. Start a timer. - Stop the timer when the verification query set passes. Record the number.
- Repeat with
--restore-untilset to a timestamp two hours before the artefact, to prove PITR works with your current log retention. - Drop
recovery_test. - Write three lines in the runbook doc: date, restore duration, anything surprising.
After two cycles you will have a defensible RTO figure instead of an aspiration. Almost every team that runs this drill for the first time finds at least one of: retention shorter than believed, credentials nobody currently holds, a chain broken by a retention script, or a restore that takes three times longer than the SLA promises. Finding those on a Tuesday afternoon is enormously cheaper than finding them at 03:00.
A minimal automation skeleton
#!/usr/bin/env bash
set -euo pipefail
DB=neo4j
DEST=/backups/$DB
TYPE=${1:-diff} # 'full' on Sundays, 'diff' otherwise
neo4j-admin database backup "$DB" \
--to-path="$DEST" --type="$TYPE" \
--from=neo4j-02.internal:6362 --compress=true
neo4j-admin database inspect --from-path="$DEST" "$DB" \
| tee /var/log/neo4j/backup-inspect.log
aws s3 sync "$DEST" "s3://acme-neo4j-backups/$DB/" --only-show-errors
Wrap it so a non-zero exit pages someone. A silent backup failure is indistinguishable from success right up until the day it matters.
Where this usually goes wrong
- Backups taken from the leader during peak hours. Use a secondary, or schedule against your real traffic profile rather than a guess.
- Retention policy applied to files, not chains. Deleting "anything older than 14 days" will happily orphan a differential chain whose full backup is 15 days old.
- No off-region copy. Same-region object storage protects against disk failure, not against a region event or an account compromise.
- PITR assumed, never configured. Log retention defaults are short; PITR without adequate
db.tx_log.rotation.retention_policyis a plan on paper only. - The drill never scheduled. This is the one that produces every other item on the list.
Once this is in place, pair it with the operational review in our Neo4j health check checklist — backups and index/query health are the two halves of the same conversation.
If you want a second pair of eyes on your backup topology, retention maths, or a first supervised recovery drill against a copy of production, our senior Neo4j consultants do exactly this work — get in touch.