Backup and restore
What must be backed up, encrypted backups with mongodump and age, restoring, and what to do after a restore.
backd keeps all state in MongoDB. The config directory describes the structure (collections, validators, indexes, rules), and provisioning can rebuild it at any time, but not the data. Back up MongoDB, and keep the config directory in version control.
What a backup must include
| Database | Holds | Rebuilt from config? |
|---|---|---|
<realm>__<database> | your documents | no |
<realm>___system | the realm’s users (emails, roles, disabled flag), password hashes, sessions, API keys, invitations, failed-login counters, the audit trail, encrypted function secrets, invocation history, async jobs (queued, running or recently done) and idempotency claims (see system databases) | no, except role seeds |
What the configuration can and can’t rebuild:
Rebuilt from CONFIG_DIR by provisioning | Only in a backup |
|---|---|
| Collections, their validators and indexes | Documents |
| The system databases’ collections, validators and indexes | Users, their sign-in methods (password hashes) and sessions |
Role seeds from realm.yaml, for users who exist | Role assignments made with backd user add-role or the admin API |
| API keys and invitations | |
| Function secrets’ encrypted values, invocation history, async jobs, idempotency claims |
Without a backup of <realm>___system, losing MongoDB loses every account, password and API key of the realm; role seeds come back only for users who sign up or are created again. backd databases lists every database to back up.
System databases are sensitive:
- Password hashes are argon2id, but weak passwords can still be cracked offline.
- Session tokens, API keys and invitation tokens are stored as SHA-256 hashes of 256-bit random values, so their hashes are useless to an attacker.
- Emails are personal data.
Encrypt every backup, and keep the decryption key away from the servers that write the backups.
Encrypted backups
The production reference includes a backup job, ops/backup.sh. It streams mongodump straight into age, so no unencrypted copy ever touches the disk:
mongodump --archive --gzip | age -r age1… > backd-20260927T031500Z.archive.gz.age
- Public-key encryption. Backups are encrypted to one or more age public keys (
BACKUP_RECIPIENTS). The server only holds public keys, so a compromised server can’t read old backups. List several recipients (for example two operators and a break-glass key kept in a safe) so no single lost key loses the backups. - Tamper-evident. age authenticates what it encrypts: a modified or truncated backup fails to decrypt instead of restoring corrupt data.
- Least privilege. The job connects as a
backupuser whose role can only read the collections the config uses (find,listIndexes,listCollections). The provision job creates it whenBACKD_BACKUP_PASSWORDis set. What that role can read defines what the backup contains: the configured data and system collections, and no MongoDB users or other databases. - Never unencrypted. Without
BACKUP_RECIPIENTS, the job refuses to run. - Retention. It keeps the newest
BACKUP_KEEPbackups (default 14) in the backup directory and deletes older ones.
Setting it up
On a machine other than the server, create the key pair and keep the private key offline (a password manager, a hardware token, a safe):
age-keygen -o backup-identity.txt # prints: Public key: age1…To protect the private key with a passphrase as well, encrypt it:
age -p backup-identity.txt > backup-identity.txt.age.Put the public key or keys in
.envon the server, and run the provision job so thebackupuser exists:BACKUP_RECIPIENTS=age1… age1…docker compose --env-file .env run --rm --no-deps provisionSchedule the backup, for example nightly from cron:
15 3 * * * cd /srv/backd/deploy/production && docker compose --env-file .env --profile ops run --rm --no-deps backupCopy the backup directory (
BACKUP_DIR, defaultdeploy/production/backups/) off the server, for example withrcloneoraws s3 syncto storage with object lock or versioning, so that neither a server compromise nor ransomware can delete every copy.Alert when no new backup has appeared for more than a day.
The job doesn’t need backd to be running, and backd keeps serving during a backup.
Consistency
mongodump copies collections one after another, not as one snapshot. Writes made during the dump may be only partly included: for example, a user created during the dump might be restored without their password. For a point-in-time snapshot, run MongoDB as a replica set and use your platform’s volume snapshots, or a managed MongoDB’s continuous backups. Encrypt those too.
Restoring
Restoring needs the admin credentials and the private key. The restore job, ops/restore.sh, decrypts with age and pipes the archive into mongorestore --drop. Each collection in the backup replaces the current one; other collections and databases are left alone.
Copy the backup file into the backup directory, and stop
backd:docker compose --env-file .env stop backdRestore:
docker compose --env-file .env --profile ops run --rm --no-deps \ -v /path/to/backup-identity.txt:/run/backup-identity:ro \ restore /backups/backd-20260927T031500Z.archive.gz.ageTo restore only part of a backup, add
mongorestoreoptions after the file, for example--nsInclude='blog__main.*'for one database.Run the provision job, which reapplies validators, indexes and grants, then start
backd. It runs inverifymode, so it refuses to start if anything is still missing:docker compose --env-file .env run --rm --no-deps provision docker compose --env-file .env up -d backd
Remove the private key from the server when you’re done.
Onto a new server
After losing the MongoDB server, restore into a new, empty one:
Start MongoDB alone. On an empty volume, its entrypoint creates the admin user from
.env, and its health check initiates the replica set. Wait untildocker compose psshows it healthy (the primary) before restoring:docker compose --env-file .env up -d --no-deps mongoRestore the backup, as in step 2 above.
Run the provision job. It recreates
backd’s least-privilege user and thebackupuser, which a backup ofbackd’s databases doesn’t contain, and reapplies validators and indexes.Start the rest:
docker compose --env-file .env up -d.
Sessions and API keys from the backup work again, so clients don’t need new credentials. Then go through After a restore.
Practise it
Practise restores regularly on a scratch environment. The reference’s test (make prod-test) runs the whole cycle on every change:
- a user saves a private draft, and a backup is taken;
- the whole stack goes down and MongoDB’s volume is deleted;
- a new, empty MongoDB starts, and the test checks it has no
backddatabases; - the backup is restored, then the provision job and the stack run;
- the test checks that the user logs in with their password and reads their own draft (still hidden from anonymous callers), and that an API key and the published documents are back.
After a restore
Before reopening the service:
API keys revoked since the backup work again. Revoke them again (
backd apikey list,backd apikey revoke). Keys created since the backup are gone; issue new ones.Users disabled or deleted since the backup are back, with their roles as they were. Disable or delete them again. Roles seeded in
realm.yamlare reapplied at startup.Password changes since the backup are undone, so an old password that was changed because it leaked works again. Ask affected users to change their passwords, or set new ones with
backd user set-password.Sessions come back, including ones that were logged out. Sessions that have expired since are refused, and MongoDB deletes them. If in doubt, end every session in the realm, and everyone logs in again:
docker compose --env-file .env run --rm --no-deps provision \ sh -c 'mongosh "$MONGO_URI" --quiet --eval "db.getSiblingDB(\"blog___system\").sessions.deleteMany({})"'Failed-login counters come back too; they expire within 15 minutes.
If a backup leaks
An encrypted backup is safe for as long as its private key is. If a backup leaks unencrypted, or leaks together with its key, treat it as a breach of the realm’s accounts:
- end every session (see above);
- revoke and reissue every API key;
- have every user change their password (
backdcan’t send reset emails yet, so plan how to reach them); - rotate the age key pair.