The database alone restores your apps and users but leaves every secret unreadable. The master key alone restores nothing. Without the agent CA key a restored control plane generates a new CA, so every node agent has to be re-enrolled; the escrow bundle carries it so they reconnect on their own.
Threat model
What the drill identity changes: if you set
APP_CONTROL_PLANE_DRILL_IDENTITY_FILE, the server holds a private key that can decrypt backups so it can prove a full restore. Anyone who can read that key file on the server could already read the database, so the exposure is the same as a compromised server. Without it, drills verify only checksums and the manifest and say so (“partial”).
Set it up
The dashboard has a guided checklist under Settings, Control plane backup. The same steps on the CLI:1
Add a storage destination
Connect a bucket under Settings, Storage destinations (see object storage). Use a bucket with versioning enabled.
2
Make a key pair on your own machine, not the server
age1) is printed. The private key is written to the file with mode 0600 and never overwritten. Store it offline: a password manager, a hardware token, or a printed copy in a safe. Repeat on a second person’s machine and add both public keys, so either can restore.3
Point backups at the destination and your keys
4
Take the first backup and look at it
5
Write the escrow bundle and store it offline
escrow.age (the master key and the agent CA files, encrypted to your recipients) and escrow.age.instructions.txt. Do not put them in the backup bucket.6
Run a drill
levelrail-cli doctor then reports control_plane_dr as ok.
Schedule, retention and env defaults
All recipients (and the drill identity) must be the same kind of key: age refuses to mix classic
age1... keys with post-quantum age1pq1... keys, and the settings form and CLI reject the combination. keys generate makes classic keys by default and post-quantum ones with --hybrid (decrypting those outside this tool needs age 1.3 or newer).
A per-install setting wins over the env default. Retention keeps the newest backup of each of the last N days, ISO weeks and months, the union of the three, and always the newest backup. Pruning removes at most the configured number of objects per run, oldest first, and also cleans up upload leftovers older than a day.
What is stored
Objects go under the destination as:.db.age file is the gzipped SQLite snapshot (taken with VACUUM INTO, integrity checked) encrypted to your recipients. The .json manifest is written last, so a backup without a manifest is an incomplete upload and is ignored. It holds no key material:
A run is idempotent per schedule tick: if the manifest for that tick already exists nothing is uploaded again, and a run cut short (crash, network) is redone over the same keys on the next tick.
Secrets are bound to their storage slot (table, row and column), not to the machine, so a backup restored onto a new server decrypts as long as it has the same master key.
Restore
Restore is an offline command on the server, because a database cannot be swapped under a running control plane. It refuses to run while another process has the database open. The full sequence for a lost machine (recover the key, rehearse, restore, start) is in the runbook below.--from ending in / picks the newest complete backup under that prefix. A full key restores that one, and a local .db.age file (with its .json beside it) works too. Add --dry-run to download, verify, and decrypt the backup and run integrity_check without touching the live database.
The command checks, in order: the manifest, the ciphertext SHA-256 and size against the manifest, decryption, the plaintext SHA-256, SQLite integrity_check, that the schema is not newer than this binary (otherwise it refuses, run a newer release), and that the backup’s install id matches (this server’s, if it has a database, and the one recorded inside the backup). It then writes the new database to a temporary file in the data directory, syncs it, keeps the current database as levelrail.db.pre-restore (and its -wal and -shm files) using hard links, and renames the new file over the live name in one step. A crash at any earlier point leaves the old database untouched. An earlier .pre-restore copy is kept under a timestamped name.
--force-install-id accepts a backup from a different install (moving to a new install id on purpose). --endpoint, --region and --path-style describe the bucket; credentials come from AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
Every step above, and the failure cases (an interrupted restore, a tampered backup, a wrong key, a newer schema, a mismatched install), is exercised by a test that runs in CI against an in-memory S3 server and generated keys.
For contributors: which test covers which step
For contributors: which test covers which step
Runbook: the control plane machine is gone
This is the exact sequence that was run, end to end, against a real S3 server (SeaweedFS) with generated keys, restoring into an empty second data directory after stopping the original control plane. Replace the placeholders. What you need before the disaster: the age private key (backup-identity.txt),
escrow.age, the bucket name, endpoint and access keys of the backup bucket,
and the install id (the cp-backups/<install-id>/ prefix in the bucket;
levelrail-cli control-plane-backups list --offbox shows it while the old server
still answers).
1
Provision the new machine
Install the same release (or a newer one). Do not start the control plane. Docker must be installed.
2
Recover the master key and agent CA
This needs no running server and no network, and can run on your own machine or the new one:You get
master.key, agent-ca.crt.pem, and agent-ca.key.pem, mode 0600, and an existing file is never overwritten. Delete ./recovered afterwards. Alternatively set APP_MASTER_KEY instead of using the file. Without --extract, escrow open prints only the master key to stdout.3
Rehearse
A dry run touches nothing and checks the manifest, checksum, decryption,
integrity_check, schema, and install id:4
Restore
Run the same command without
--dry-run. It writes /var/lib/levelrail-data/levelrail.db and keeps any database that was already there as levelrail.db.pre-restore.5
Start the control plane
Use the same environment as before (the same
APP_* settings, in particular APP_DATA_DIR). It applies any newer migrations (taking a pre-upgrade snapshot first), and the reconciler converges the containers toward the restored desired state.6
Check it worked
levelrail-cli databases list and levelrail-cli apps list show everything. levelrail-cli backups trigger <database> --target <id> succeeds, which proves the restored secrets decrypt with the recovered master key. levelrail-cli control-plane-backups run-now uploads a new backup. Then run levelrail-cli control-plane-backups drill run.- Anything that changed after the last backup (new apps, tokens, deploys): the restored state is the backup’s.
- Managed database data and app volumes. They live on the node’s disks and have
their own database backups and
volume backups. On a truly new machine, restore them
from those backups (
backups restore-as-new) after the control plane is up. - Without the escrow bundle or
APP_MASTER_KEY, no stored secret can be read. - Without the agent CA files the restored control plane makes a new CA and every node agent must be re-enrolled with a new join token.
Local snapshots
A local snapshot is on the dead machine’s disk and never contains the master key. For a snapshot you still have (for example one downloaded earlier, or the pre-upgrade copy after a bad upgrade),levelrail restore-snapshot (--list, --dry-run latest, --yes latest) and levelrail restore-db <file> do the same offline swap and keep the old database as .before-restore. See Control plane backup.
What has and has not been verified
The runbook above was run end to end on one machine: a control plane built from the release branch, real Docker, and a SeaweedFS S3 bucket, restoring into a second data directory. Off-box backups, escrow, a passing drill,restore --dry-run, the real restore from s3://, restore-snapshot, and restore-db were all exercised.
Not verified:
- A restore onto a physically different machine, and re-enrolling node agents with a restored or regenerated CA.
- Public ingress certificates and DNS after the move (the new machine has a new address; update DNS yourself).
- Provider specifics for AWS S3, R2, and B2. They use the same code path with a different endpoint, but only an S3-compatible server on localhost was used.
- Restoring a backup made by an older release into a newer one across many migrations. The schema check refuses a newer backup on an older binary.
Restore drills
A backup nobody has restored is a hope. The drill runs on its own schedule (weekly by default, and once right after the first backup), and on demand withdrill run or the Run drill button. It downloads the newest complete backup and restores it into a temporary directory through the same code path as a real restore, then runs integrity_check, compares the applied migrations with the manifest, and counts tables and rows. It never touches the live database.
- With
APP_CONTROL_PLANE_DRILL_IDENTITY_FILEset, its public key is added as a recipient of every backup, so the drill can decrypt and do the full check. - Without it the drill is partial: it downloads and verifies the ciphertext checksum and manifest only. The dashboard,
drill statusand doctor all say so.
drill status (exit code 1 when the last drill failed or none has run) and doctor.
Alerts and the doctor check
Off-box failures and failed or overdue drills fire the existingcontrol_plane_backup_stale alert rule, so if you already created that rule you are covered. Create it if you have not:
levelrail-cli doctor has a control_plane_dr check. It warns, with a fix, when off-box backups are off, no recipient is set, the last run failed, a run is overdue, escrow was never generated or confirmed, no drill has run, the last drill failed or is overdue, or the escrow destination is the backup bucket.
After rotating the master key
Rotating the master key makes your escrow bundle stale: it still holds the old key. Runescrow again and replace the stored copy. If the key comes from APP_MASTER_KEY, update that variable first, because the server can only read the key from its file or that variable, not from memory.
API and MCP
Restore is intentionally not an API call. The escrow endpoint returns the bundle already encrypted to your public keys; the plaintext master key never leaves the server process. The read-only MCP tools are
get_control_plane_dr_status, list_control_plane_offbox_backups and get_control_plane_drill_status. None returns key material.
Next steps
Control plane backup
Local snapshots, verification, and the offline restore commands.
Master key rotation
Rotate the key, then refresh your escrow bundle.