Skip to content

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.

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:

KeyDefault
backups.enabled0
backups.cron0 3 * * *
backups.keep7 (1–1000)
backups.files1
backups.s3.enabled0
backups.s3.endpoint / bucket / region / prefix— / — / auto / vaultbase-backups/
backups.s3.access_key_id / secret_access_key—

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 in uploads/ 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: keep uploads/ 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 409 while 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 with aws s3 sync or rclone) 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.
GET /api/v1/admin/backups ← stored snapshots + schedule status
POST /api/v1/admin/backups ← back up now
GET /api/v1/admin/backups/<name> ← download a stored snapshot
DELETE /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 up

A 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.

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=1 to 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.

To restore a live app without clients seeing a half-finished state:

  1. Turn on maintenance mode, with background work paused. The restore keeps it on.
  2. Restore. Decide first whether users deleted since the backup should come back.
  3. Read the restore’s summary on the backups page. Check any account it couldn’t carry over.
  4. Look through the dead jobs and webhook deliveries it names, and retry the ones that should still run.
  5. 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>/.secret and <dataDir>/.encryption-key (keys). To restore on another host, bring the encryption key too (VAULTBASE_ENCRYPTION_KEY or 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 after maintenance.retention_days, default 30)

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:

Terminal window
# 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 snapshot
vaultbase backup --to ./snapshot.db.gz --gzip
# straight to object storage — no local intermediate
vaultbase backup --to s3://my-bucket/vaultbase/$(date -u +%F).db
vaultbase backup --to r2://my-bucket/snapshot.db # Cloudflare R2
vaultbase backup --to b2://my-bucket/snapshot.db # Backblaze B2

S3-family destinations read credentials from the standard env vars:

AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
AWS_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.

Terminal window
VAULTBASE_DATABASE_URL=postgres://… vaultbase backup --to ./backups/$(date -u +%F).dump

Combine with cron / systemd-timer:

0 3 * * * /usr/local/bin/vaultbase backup --to s3://acme-backups/vaultbase/$(date -u +\%F).db --quiet

vaultbase 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.

Designed for: dev → staging → prod schema sync. Round-tripping schemas via git.

Settings → Migrations → Download snapshot in the admin, or:

GET /api/v1/admin/migrations/snapshot
→ JSON body, downloads as vaultbase-snapshot-YYYY-MM-DD.json

Snapshot 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 snapshotCreating the collectionApplying to an existing one (sync)
nullPublicSet to public
"" or an expressionThat ruleSet to that rule
Left outAdmin 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.

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:

ModeWhat it does
additive (default, safe)Creates missing collections. Skips existing ones — never modifies them.
syncAlso 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": []
}
}
  1. Build your schema in dev via the admin UI.
  2. Download snapshot → commit schema.json to git.
  3. 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).
  4. App data flows in via the records API — no schema work in prod.
  • 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