Backups & migrations
Two complementary tools — a full data backup (SQLite snapshot or Postgres pg_dump) and a JSON snapshot for schema sync across environments.
Vaultbase ships two distinct mechanisms for persistence portability:
- Backup & restore — full database snapshot (data + schema). Settings → Backup & restore. For disaster recovery and DB-level rollback.
- Migrations — JSON snapshot of the schema (no data). Settings → Migrations. For shipping schemas between environments.
Backup & restore (data + schema)
Section titled “Backup & restore (data + schema)”Scheduled backups
Section titled “Scheduled backups”Settings → Backup & restore turns on built-in scheduled backups:
- Schedule: a cron expression in UTC. Default
0 3 * * *(daily, 03:00). - Keep: how many snapshots to keep (default 7). Older ones are deleted after each run, on disk and in S3.
- Include uploaded files (on by default, since 0.34.0): with local file
storage, each backup also keeps the files in
<dataDir>/uploads/. See Uploaded files. - Copy to S3: optionally upload each snapshot to an S3-compatible bucket (AWS S3, Cloudflare R2, Backblaze B2, MinIO). Its credentials are separate from file storage. Use a private bucket: a snapshot contains password hashes and every setting.
Snapshots are written to <dataDir>/backups/ as
vaultbase-YYYYMMDD-HHMMSS-mmm.db. They’re taken with SQLite VACUUM INTO,
so they’re consistent while the server keeps serving writes. The copy runs on
its own read-only connection in a background thread, so requests aren’t held
up while it runs (before 0.36.0 it ran on the main thread and every write,
sign-in and admin request waited for it). The download from the admin streams
from disk. In cluster mode
only one worker runs each scheduled backup.
The same page lists stored snapshots, with download, restore and delete buttons, and shows the last run, next run and last error. Back up now runs the same pipeline immediately.
Settings keys, if you prefer PATCH /api/v1/admin/settings:
| Key | Default |
|---|---|
backups.enabled | 0 |
backups.cron | 0 3 * * * |
backups.keep | 7 (1–1000) |
backups.files | 1 |
backups.s3.enabled | 0 |
backups.s3.endpoint / bucket / region / prefix | — / — / auto / vaultbase-backups/ |
backups.s3.access_key_id / secret_access_key | — |
Uploaded files
Section titled “Uploaded files”With local file storage, each backup also keeps the uploaded files, so a restore brings back the records and the files they point at together.
- Next to each snapshot sits a manifest,
vaultbase-…-mmm.files.json, listing every file inuploads/with its size and SHA-256. The files themselves go in a store shared by all backups,<dataDir>/backups/files/, once each: after the first backup, a run copies only the files uploaded since. - The thumbnail cache (
uploads/.thumbs) isn’t kept; it’s rebuilt on demand. Dotfiles and symlinks aren’t kept either: keepuploads/on one disk. - A file that can’t be read (wrong owner, say) is left out and the backup goes on; the backup’s status then says how many files were left out, and its manifest names them.
- One backup runs at a time, across cluster workers too: Back up now
answers
409while another is running. - Restoring a stored backup puts back every file of its manifest that is
missing or whose contents differ, and the message says how many. Files
uploaded since the backup stay in
uploads/, though no record points at them any more. - Pruning a backup deletes its manifest too, then the stored files no remaining backup lists.
- With S3 storage (or another driver) the files already live off the server, and backups don’t keep them.
- The S3 copy mirrors the same layout under its prefix: snapshots,
manifests, and
files/. To restore on a new server, copy the prefix into<dataDir>/backups/(for example withaws s3 syncorrclone) and restore from Settings → Backup & restore, database and files together. - Restoring from an uploaded file (
POST /api/v1/admin/restore) restores the database only: an upload is a single file, without the store.
HTTP API
Section titled “HTTP API”GET /api/v1/admin/backups ← stored snapshots + schedule statusPOST /api/v1/admin/backups ← back up nowGET /api/v1/admin/backups/<name> ← download a stored snapshotDELETE /api/v1/admin/backups/<name>POST /api/v1/admin/backups/<name>/restore ← restore a stored snapshot (?bring_back_deleted_users=1)GET /api/v1/admin/backup ← download a fresh snapshot (not stored)POST /api/v1/admin/restore ← multipart upload (one request: up to 128 MB; same option)
POST /api/v1/admin/restore/uploads ← start an upload in pieces → { id, chunk_size }PUT /api/v1/admin/restore/uploads/<id>?offset=<bytes> ← one piece (raw body, up to chunk_size)POST /api/v1/admin/restore/uploads/<id>/finish?name=<file> ← restore from the pieces (same option)DELETE /api/v1/admin/restore/uploads/<id> ← give upA request body is capped at 128 MB, so a larger backup is sent in pieces
(since 0.37.0; the admin’s Upload button always does). Each PUT appends
at its offset; one sent at the wrong offset answers 409 with the size
the server has, so a piece whose answer was lost is sent again from there.
An upload left unfinished for a day is deleted.
On Postgres, backups are pg_dump archives (.dump), a restore runs while
the server keeps serving, and the data it replaces is kept for 7 days with
an undo: see Restore on Postgres.
The rest of this section describes SQLite.
Restore replaces all current data. The file is checked first: it must
pass SQLite’s integrity check and contain a vaultbase_collections table.
A file that fails is refused with 422 and nothing changes.
The server then stops its read workers and the WAL checkpointer (each holds
its own connection to the file), swaps the file in, and starts them again.
Reads that arrive meanwhile run on the main thread. The previous database is
kept next to it as data.db.bak.<ms>. The JWT signing key is unchanged, so
existing tokens stay valid — except sessions and API tokens revoked since the
backup was taken (individually, by “sign out everywhere”, or by a password
reset), which stay revoked, and those of accounts deleted since (see
What a restore keeps). Collections, hooks, routes and jobs are reloaded
from the restored database straight away.
What a restore keeps
Section titled “What a restore keeps”A restore brings back data, not the decisions you made since the backup. All of this holds on SQLite and Postgres, for backups of the same install.
- Credentials. An account whose sign-in details changed since the backup keeps its current password, email, TOTP, recovery codes (still usable; spent ones stay spent) and provider links. To undo one of these, change it back after the restore.
- Sessions ended since the backup stay ended: revoked sessions and API tokens, “sign out everywhere” and password resets. These carry over even when another step fails.
- Admins deleted since the backup stay deleted, and the API tokens they minted are revoked. A restore never leaves the install without an admin.
- Users deleted since the backup stay deleted (since 0.40.0), with their
provider links and recovery codes, so a member you removed doesn’t get
back in with an old session. To restore users deleted by mistake, turn on
Bring back users deleted since the backup on the backups page, or add
?bring_back_deleted_users=1to the restore request: they come back signed out of every session. Before 0.40.0 they always came back, with their sessions. - Background work isn’t run twice (since 0.40.0). A job queued, or a webhook delivery pending, when the backup was taken is checked against the current data. If it is still waiting, it keeps its place. If it has run, or been removed, since, it’s marked dead, so it doesn’t send an email or make a charge a second time; retry it from the job list in Hooks, or redeliver it from Webhooks, if it should run. Work queued after the backup isn’t kept: it belongs to data the restore rolled back.
- Maintenance mode stays as it was before the restore (since 0.40.0). Before, it followed the backup, which usually turned it off and let background work start again at once.
An account or admin created after an older snapshot isn’t treated as deleted, so it survives restoring a newer backup after an older one (or an undo on Postgres). Restoring another install’s backup brings its accounts and admins as they are in it.
The restore’s answer lists what it did: the accounts that kept their credentials, the admins and users it left deleted or brought back, the work it marked dead, and anything it couldn’t carry over (an email now held by an account the restore brought back, say): the backup’s value is in place for those, so check them. The backups page shows it after the page reloads.
Before you reopen
Section titled “Before you reopen”To restore a live app without clients seeing a half-finished state:
- Turn on maintenance mode, with background work paused. The restore keeps it on.
- Restore. Decide first whether users deleted since the backup should come back.
- Read the restore’s summary on the backups page. Check any account it couldn’t carry over.
- Look through the dead jobs and webhook deliveries it names, and retry the ones that should still run.
- Turn maintenance mode off.
In cluster mode (vaultbase cluster) online restore answers 409.
Offline restore is the most robust path, and the only one in cluster mode:
stop the service, replace data.db (and remove data.db-wal and
data.db-shm), and start it again.
A database backup holds only the database. Scheduled and Back up now
backups also keep uploaded files (see Uploaded files); a
download or the CLI’s vaultbase backup doesn’t. Not included anywhere:
- hook, route, job and extension files kept on disk (keep them in git)
<dataDir>/.secretand<dataDir>/.encryption-key(keys). To restore on another host, bring the encryption key too (VAULTBASE_ENCRYPTION_KEYor the file): without the original key, encrypted fields and stored secrets can’t be read. Bring the JWT secret as well if existing tokens should stay valid.- secrets and config passed through the environment or a config file
<dataDir>/logs/(request logs; deleted aftermaintenance.retention_days, default 30)
vaultbase backup CLI
Section titled “vaultbase backup CLI”The same binary can take a snapshot without going through the HTTP API. Useful for cron jobs that already have shell access to the host:
# local file (atomic VACUUM INTO — no WAL sidecars, no admin token)vaultbase backup --to ./backups/snapshot-$(date -u +%Y%m%d-%H%M%S).db
# gzip the snapshotvaultbase backup --to ./snapshot.db.gz --gzip
# straight to object storage — no local intermediatevaultbase backup --to s3://my-bucket/vaultbase/$(date -u +%F).dbvaultbase backup --to r2://my-bucket/snapshot.db # Cloudflare R2vaultbase backup --to b2://my-bucket/snapshot.db # Backblaze B2S3-family destinations read credentials from the standard env vars:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_REGION (default: auto)AWS_ENDPOINT_URL (required for r2:// and b2://, and for MinIO)For r2:// and b2:// you can set R2_ENDPOINT / B2_ENDPOINT instead of
AWS_ENDPOINT_URL. Credentials and the endpoint are checked before the
snapshot is taken, so a misconfigured run fails without leaving a database
copy behind.
The CLI uses SQLite VACUUM INTO so it’s safe to run against a live
database — the snapshot is internally consistent without pausing writes.
On Postgres (VAULTBASE_DATABASE_URL set) the CLI runs pg_dump instead and
writes the same .dump archive the backups page makes, of the server’s
schema, also consistent without pausing writes. It needs pg_dump of the
server’s major version or newer, on the PATH or named by VAULTBASE_PG_DUMP.
--gzip is refused, because the archive is compressed already. A local file
appears only once the dump is complete. Since v0.29.0.
VAULTBASE_DATABASE_URL=postgres://… vaultbase backup --to ./backups/$(date -u +%F).dumpCombine with cron / systemd-timer:
0 3 * * * /usr/local/bin/vaultbase backup --to s3://acme-backups/vaultbase/$(date -u +\%F).db --quietvaultbase backup copies the database only. For uploads with it, snapshot
the filesystem with restic / borgbackup / rsync — they’re just files —
or use the scheduled backups, which keep them.
Schema migrations (no data)
Section titled “Schema migrations (no data)”Designed for: dev → staging → prod schema sync. Round-tripping schemas via git.
Export
Section titled “Export”Settings → Migrations → Download snapshot in the admin, or:
GET /api/v1/admin/migrations/snapshot → JSON body, downloads as vaultbase-snapshot-YYYY-MM-DD.jsonSnapshot shape:
{ "generated_at": "2026-04-27T12:34:56.000Z", "version": 1, "collections": [ { "name": "posts", "type": "base", "fields": [ { "name": "title", "type": "text", "required": true }, { "name": "body", "type": "text" } ], "list_rule": null, "view_rule": null, "create_rule": "@request.auth.id != \"\"", "update_rule": "", "delete_rule": "", "history_enabled": false, "indexes": [{ "fields": ["status", "created_at"], "unique": false }] }, ... ]}Drops the DB-only fields (id, created_at, updated_at) since name is
the cross-environment identifier.
A singleton collection is exported with "type": "singleton". Applying the
snapshot creates the collection together with its one record, fields empty. The
record’s values are data, so they aren’t in the snapshot.
Exports always write all five rules, public ones (null) included. In a
snapshot you write by hand:
| Rule in the snapshot | Creating the collection | Applying to an existing one (sync) |
|---|---|---|
null | Public | Set to public |
"" or an expression | That rule | Set to that rule |
| Left out | Admin only ("") | Left as it is |
Since 0.36.0 a snapshot also carries each collection’s record-history setting
(history_enabled, left as it is when left out) and the indexes made in the
admin (indexes; the ones the server makes itself come with the schema).
Applying one creates the indexes that are missing, in either mode, and never
drops one.
So a sync never opens or closes access because a rule is missing.
Preview the diff before applying
Section titled “Preview the diff before applying”POST /api/v1/admin/migrations/diff{ "snapshot": { ...JSON snapshot... } } → { "data": { "added": [ { "name": "audits", "type": "base" } ], "modified": [ { "name": "posts", "type": "base", "changes": ["fields: 1 added", "view_rule changed"] } ], "unchanged": [ { "name": "users" } ], "removed": [ { "name": "drafts" } ] } }Returns admin-friendly change strings — how many fields would be added, removed or modified, which rules differ, and so on. Nothing is applied; this is purely read-only.
The Admin Migrations tab shows this preview before Apply runs:
chips for added / modified / unchanged / removed plus an expandable
<details> block listing the per-collection changes for everything in
modified. The Apply button stays disabled until the diff has loaded
— you can’t accidentally apply a snapshot you haven’t reviewed.
Settings → Migrations → Apply snapshot with mode selector, or:
POST /api/v1/admin/migrations/apply{ "snapshot": { ...JSON snapshot... }, "mode": "additive" // default; "sync" updates existing too}Modes:
| Mode | What it does |
|---|---|
additive (default, safe) | Creates missing collections. Skips existing ones — never modifies them. |
sync | Also updates existing collections to match the snapshot (fields, view query, and the rules the snapshot carries). Removed fields drop their column and data. Confirm before running. |
Neither mode ever deletes a collection. Drop manually if needed.
Response:
{ "data": { "created": ["posts", "users"], "updated": [], "skipped": ["existing_collection"], "errors": [] }}Recommended workflow
Section titled “Recommended workflow”- Build your schema in dev via the admin UI.
- Download snapshot → commit
schema.jsonto git. - CI / deploy script:
- Spin up prod with empty
<dataDir>(or existing). - Apply the snapshot (additive on first deploy, sync on follow-ups — gated by manual confirm).
- Spin up prod with empty
- App data flows in via the records API — no schema work in prod.
Limitations
Section titled “Limitations”- Type changes within a field are blocked (would require data conversion). Drop + re-add the field to change a type.
- Collection type changes (e.g. base → auth) are blocked in sync mode — drop the collection manually first. A collection can’t be turned into a singleton or out of one either: create a new collection and move the data.
CSV import / export (data, base collections)
Section titled “CSV import / export (data, base collections)”For bulk data, not schemas:
- Records page → Export — downloads all rows of a base collection as CSV. Excludes password fields. Object/array values JSON-encoded into a single cell.
- Records page → Import — upload a CSV; every row becomes a new record, rows go through validation, and per-row errors are returned in the response summary. See CSV import / export.
GET /api/v1/admin/export/<collection>POST /api/v1/admin/import/<collection> ← Content-Type: text/csv