Skip to content
v0.5.1GitHub

Operating

Backup and restore

applies to v0.5.1

A backup, started with POST /store-api/backups or by a schedule in [backup], is a logical NDJSON archive of key-value, JSON and relational domains. The scope picks what goes in: everything, one engine, one domain of one engine, or one domain name in every engine.

An archive does not copy files. It lists what the domains hold, so it restores into any shard count, and it can move data to another server. The server restores archives of format version 1 and 2. Backups and restores are admin-only.

Backups are off. While backup.enabled is false, every backup endpoint answers 503 Service Unavailable: backup is disabled (backup.enabled = false).

[backup]
enabled = true
dir = "/var/lib/luradb/backups"

backup.dir is where the archives are kept, one file bk_<time>_<name>.ndjson each. The packaged file leaves the path relative, which under the service is /var/lib/luradb/luradb_backups on the data volume. A copy on the volume that fails with the data is not a backup, so give the directory a volume of its own: mount it below /var/lib/luradb, or allow its path for the service as described in systemd and Docker. Use a directory per server, because retention deletes archives by schedule name. Whether a backup may start while the server refuses writes for lack of space is described in Resource guard.

Only one backup or restore runs at a time. A second one answers 409 Conflict: a backup or restore job is already running.

POST /store-api/backups takes a scope and answers 202 Accepted with the id of the job, which runs in the background:

Terminal window
curl -s -X POST http://127.0.0.1:3000/store-api/backups \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"scope": "domain:orders"}'
{ "id": "bk_20261103T223029Z_domain-orders", "state": "running" }
Scope Covers
all Every enabled engine, all active domains.
kv, json, rel All domains of one engine.
kv:<domain>, json:<domain>, rel:<domain> One domain of one engine.
domain:<name> The domain of that name in every engine that has one.

A scope that does not parse answers 400, a scope that names a missing domain 404, and a scope that needs the JSON or relational engine while it is switched off 503. The id is bk_, the UTC time and the scope (or the name of the schedule), with _2, _3 and so on added for a second backup in the same second.

With "include_auth": true, the archive also holds the users, their permissions and the hashes of their API keys. It applies to the scopes all and kv only; for any other scope the flag is ignored. Treat such an archive like the key file of the server.

The list shows every finished archive and, in running, the job in progress:

Terminal window
curl -s http://127.0.0.1:3000/store-api/backups -H "Authorization: Bearer $KEY"
{
"backups": [
{
"id": "bk_20261103T223029Z_domain-orders",
"state": "complete",
"scope": "domain:orders",
"created_at": 1793745029,
"size_bytes": 1632,
"schedule": null,
"format_version": 2
}
],
"running": null
}

state is complete, or incomplete for a file without its checksum line, which cannot be restored. GET /store-api/backups/{id} adds the manifest of the archive:

{
"id": "bk_20261103T223029Z_domain-orders",
"state": "complete",
"scope": "domain:orders",
"created_at": 1793745029,
"size_bytes": 1632,
"format_version": 2,
"luradb_version": "0.5.1",
"include_auth": false,
"kv_snapshot_ts": 117554874241515520,
"json_snapshot_ts": 117554874241515521,
"rel_snapshot_ts": 117554874241449985,
"encoding": "hex",
"shards": 1,
"lsn_cut": { "kv": 3, "json": 4, "rel": 4 }
}

While the job runs, the same call answers "state": "running" with started_at. A finished backup is logged, and its counters are in the backup block of the metrics:

INFO luradb::api::backup: backup created admin=admin backup_id=bk_20261103T223029Z_domain-orders
INFO luradb::backup: [Backup] job 'bk_20261103T223029Z_domain-orders' completed

The archive is a text file with one JSON object per line. The first line is the manifest and the last is a checksum; the lines in between follow this order, and a domain name is on each line that belongs to a domain:

t Line
manifest Format version, id, creation time, server version, scope, include_auth, shards and lsn_cut of the server that wrote it.
kv-domain, kv A key-value domain, and one line per key: k and v in hex, and expires_at in Unix seconds, 0 for no expiry. A key in the null state has no v.
json-domain, json-index, doc A JSON domain, its index definitions, and one line per document with its key, version and content.
rel-domain, rel-table, rel-view, rel-row, rel-seq A relational domain, its tables with columns and indexes, its views as SQL text, every row, and the AUTOINCREMENT mark of each table.
auth-user, auth-perm Users and permissions, with include_auth only.
checksum The SHA-256 of every line before it and their number.
{"t":"manifest","format_version":2,"id":"bk_20261103T223029Z_domain-orders","created_at":1793745029,"luradb_version":"0.5.1","scope":"domain:orders",…}
{"t":"kv-domain","name":"orders","created_at":1793745029}
{"t":"kv","domain":"orders","k":"6c6162656c2d31303031","v":"4c4953424f4e2d30303432","expires_at":0}
{"t":"json-domain","name":"orders","created_at":1793745029}
{"t":"json-index","domain":"orders","field":"status","field_type":"string"}
{"t":"doc","domain":"orders","key":"o-1001","version":1,"content":{"customer":"Jane Doe","status":"open","total":42.5}}
{"t":"rel-domain","name":"orders","created_at":1793745029}
{"t":"rel-table","domain":"orders","name":"shipments","columns":[…],"indexes":[]}
{"t":"rel-row","domain":"orders","table":"shipments","row":{"carrier":"DHL","id":1,"label":"label-1001","order_doc":"o-1001"}}
{"t":"rel-seq","domain":"orders","table":"shipments","hw":1}
{"t":"checksum","sha256":"cdf212f273e83bcf90d915b94777739d56497a863a573b556d2bcab032579225","lines":10}

A backup does not stop the writes. For each engine, all data shards are read at one consistent point: an archive that holds a write holds every write that was confirmed before it. The relational data is read first and the key-value and JSON data after it, so a link in an archived row finds its target. The three engines are cut one after the other, not at one instant. The export pauses for 10 ms after every 500 entries, so that requests keep their latency; until 0.5.1 these were the settings backup.scan_batch_sizeremoved and backup.scan_pause_msremoved, which are fixed now. Writing goes to a .ndjson.part file that is renamed when it is complete, so a crash leaves no half archive under a real name; the next start removes the leftover.

An archive is downloaded as it lies on disk. The endpoint supports Range requests, so an interrupted download can resume:

Terminal window
curl -s -o orders.ndjson http://127.0.0.1:3000/store-api/backups/bk_20261103T223029Z_domain-orders/download \
-H "Authorization: Bearer $KEY"
curl -si http://127.0.0.1:3000/store-api/backups/bk_20261103T223029Z_domain-orders/download \
-H "Authorization: Bearer $KEY" -H "Range: bytes=0-9"
HTTP/1.1 206 Partial Content
content-type: application/x-ndjson
accept-ranges: bytes
last-modified: Tue, 03 Nov 2026 22:30:29 GMT
content-range: bytes 0-9/1632
content-length: 10
{"t":"mani

A range beyond the end answers 416 Range Not Satisfiable. While a backup is still running, its id answers 409 on download and delete. Keep a copy of the archives away from the server; that is what the download is for.

Upload takes the raw archive as the request body, not as a form. The server writes it to the backup directory while it arrives, checks the checksum and the manifest, and gives the file a new id of its own, bk_<time>_upload:

Terminal window
curl -s -X POST http://127.0.0.1:3000/store-api/backups/upload \
-H "Authorization: Bearer $KEY" --data-binary @orders.ndjson
{
"id": "bk_20261103T223032Z_upload",
"state": "complete",
"scope": "domain:orders",
"created_at": 1793745029,
"size_bytes": 1632,
"format_version": 2,
"luradb_version": "0.5.1",
"include_auth": false,
"kv_snapshot_ts": 117554874241515520,
"json_snapshot_ts": 117554874241515521,
"rel_snapshot_ts": 117554874241449985,
"encoding": "hex",
"shards": 1,
"lsn_cut": { "kv": 3, "json": 4, "rel": 4 }
}

The answer is 201 Created. An archive that fails the check, for example after a character in it was changed, is deleted again and the call answers 400 Bad Request: invalid backup file: checksum mismatch. The upload is not held to the 2 MiB limit of other requests: an archive of 12 MB was accepted in a test of v0.5.1. DELETE /store-api/backups/{id} removes an archive and answers 204, or 404 for an unknown id.

A restore reads an archive and writes its domains into the running server. It starts with POST /store-api/backups/{id}/restore and runs in the background, so the call answers 202 Accepted with a restore id, and the outcome is read from GET /store-api/restores/{id}:

Terminal window
curl -s -X POST http://127.0.0.1:3000/store-api/backups/bk_20261103T223029Z_domain-orders/restore \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"into_domain": "orders_copy"}'
curl -s http://127.0.0.1:3000/store-api/restores/rs_20261103T223030Z -H "Authorization: Bearer $KEY"
{ "restore_id": "rs_20261103T223030Z", "state": "running" }
{
"restore_id": "rs_20261103T223030Z",
"backup_id": "bk_20261103T223029Z_domain-orders",
"state": "complete",
"imported": 3,
"skipped": 0,
"failed": 0,
"errors": [],
"started_at": 1793745030,
"finished_at": 1793745030
}

The request takes three optional fields:

Field Effect
mode fail_if_exists (the default) or replace. Any other value answers 400.
into_domain Restores under this name instead of the archived one.
include_auth Also applies the users and permissions of the archive, as an upsert. Default false.
  • fail_if_exists refuses to touch a domain that exists. If any domain of the archive exists in its engine, the restore ends as failed before anything is written, with the name in the errors: {"key": "_restore_", "error": "domain_exists: orders_copy"}.
  • replace deletes the existing domains of that name first, waits until their data is purged, and then restores. The wait is the background purge of a deleted domain, which works in steps of domains.purger_interval_secs seconds, and the restore gives up after 10 minutes. Replacing a domain that exists in three engines took 27 seconds in a test of v0.5.1, although the data was small.
  • into_domain needs an archive of one domain name: the scopes kv:<domain>, json:<domain>, rel:<domain> and domain:<name>. An archive of any other scope answers 400 Bad Request: into_domain requires a backup with exactly one domain. A new name that is not a valid domain name makes the restore end as failed, with the 400 message in the errors.
  • Keys with a TTL keep their expiry as a point in time. A key whose time has passed is skipped and counted in skipped. Keys in the null state come back in the null state.
  • Failures. A key or document that cannot be written is counted in failed and the restore goes on; errors lists the first 100. A failure in a relational line, such as a table, a view or a batch of rows, ends the restore as failed at once and names the domain and the table. An archive with JSON or relational data cannot be restored while that engine is switched off.
  • The result is kept in memory. The server keeps the status of the last 20 finished restores, and all of them are gone after a restart; the outcome is in the log too: [Restore] job '…' completed, or failed: ….

An archive of format version 1, from an earlier release, and one without relational data restore as well. A format version the server does not know answers 400 Bad Request: unsupported backup format version.

A relational column of type KVREF or JSONREF holds a key. It points into the key-value or JSON domain with the same name as the relational domain, so a restore under a new name moves the links with the data. The relational domain orders_copy links to orders_copy in the other two engines:

Terminal window
curl -s -X POST http://127.0.0.1:3000/store-api/rel/orders_copy/sql \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"sql": "SELECT id, order_doc, label, carrier FROM shipments", "expand": ["*"]}'
{
"columns": [
{ "name": "id", "type": "INTEGER" },
{ "name": "order_doc", "type": "JSONREF" },
{ "name": "label", "type": "KVREF" },
{ "name": "carrier", "type": "TEXT" }
],
"expanded": {
"label": [{ "encoding": "utf8", "exists": true, "value": "LISBON-0042" }],
"order_doc": [{ "document": { "customer": "Jane Doe", "status": "open", "total": 42.5 }, "exists": true }]
},
"limit_applied": false,
"row_count": 1,
"rows": [[1, "o-1001", "label-1001", "DHL"]]
}

The copy is a copy: changing label-1001 in orders afterwards leaves orders_copy as it was. The links resolve only as far as the target domains exist:

  • Both targets exist or come with the archive. A scope of domain:orders carries the three domains, and the links work as above. A relational archive restored into a name whose key-value and JSON domains exist already finds its targets there.

  • A target domain is missing. An archive with only the relational data, scope rel:orders, restored as archive-copy with no key-value and no JSON domain of that name, reads both link columns as NULL. The links are cleared for good in the background, and creating the two domains afterwards does not bring them back:

    { "rows": [[1, null, null, "DHL"]], "expanded": { "label": [null], "order_doc": [null] } }
  • The domain exists, the key does not. The cell keeps the key and expand reports "exists": false, as for a link whose target was deleted.

If your relational tables link to keys and documents, back up domain:<name> rather than rel:<name>, so that the targets travel with the rows.

A schedule is a [[backup.schedule]] block. Repeat the block for more schedules; without one, backups run only on demand:

[[backup.schedule]]
name = "nightly"
cron = "0 3 * * *"
scope = "all"
keep_last = 7
Key Meaning
backup.schedule.name Unique, 1 to 50 characters of a-z, A-Z, 0-9, _ and -. It becomes part of the backup id and the schedule field of the archive.
backup.schedule.cron Five fields, minute, hour, day of month, month, weekday (0 to 6, 0 is Sunday), in UTC.
backup.schedule.scope A scope as in the table above.
backup.schedule.include_auth Also back up users and permissions, with the scopes all and kv. Default false.
backup.schedule.keep_last How many of this schedule’s newest complete backups to keep, at least 1.

A cron field takes *, a number, a list 1,15, a range 1-5 and steps */10 or 0-30/10. Names such as MON and special forms such as @daily are not understood. When both day of month and weekday are restricted, a day matches if either does; when one of them is *, the other decides alone. The start is refused for an expression that does not have five fields, one that never fires within four years such as 0 3 31 2 *, a duplicate name, a scope that does not parse, and backup.schedule.keep_last at 0, each with a message that names the schedule.

The scheduler looks at the clock at every full minute, takes the UTC time and starts a backup for each schedule that matches. It does not catch up: a minute in which the server was down, or in which another job held the slot, is skipped, and the log says so ([Backup Scheduler] job slot busy, skipping this tick for schedule '…'). When writes are refused for lack of space or memory and the backup directory has no volume of its own with room, the tick is skipped as well. After a scheduled backup completed, the server deletes the older complete backups of that schedule beyond backup.schedule.keep_last. Backups made on demand and uploaded archives are never deleted by retention. This is the effect of backup.schedule.keep_last at 2 on a schedule that fires every minute:

Terminal window
curl -s http://127.0.0.1:3000/store-api/backups -H "Authorization: Bearer $KEY"
{
"backups": [
{ "id": "bk_20261103T220356Z_every-minute", "state": "complete", "scope": "domain:orders", "schedule": "every-minute" },
{ "id": "bk_20261103T220400Z_every-minute", "state": "complete", "scope": "domain:orders", "schedule": "every-minute" }
],
"running": null
}

The backup block of the metrics names the last success per schedule in last_success_at_by_schedule. The scheduler does not remember the minute it fired in last. If the pause until the next minute ends a few seconds early, the same minute is evaluated twice and the schedule runs twice; a test of v0.5.1 with * * * * * did this, and the log shows the pairs (…T220157Z and …T220200Z). The second backup counts against backup.schedule.keep_last like any other.

An archive holds domain data and, on request, users. It does not hold the configuration (luradb.toml, including the admin keys), the TLS files, or the log files. Back those up as files. After a restore, a server that runs with a different shard count places every restored domain by its own count, so no migration is needed.