Configuration Backup (Meta-Backup)
The configuration backup is a copy of DBackup's own SQLite database, compressed and encrypted with a key of the Vault. A copy holds every table and every link, so no part of DBackup needs export or import code, and a table a later version adds is in it without a word.
Overview
Live database ──VACUUM INTO──▶ Copy ──▶ without sign-ins, caches and (optionally) history
+ keys row ──▶ gzip ──▶ AES-256-GCM ──▶ DestinationCore Concepts
- Complete by construction: the copy is the database, templates, folders of file jobs, second factors and passkeys included
- Keys travel with it: a row
configBackup.copyKeysinSystemSettingof the copy holdsENCRYPTION_KEY,BETTER_AUTH_SECRET, the version and the time. It only ever exists in a copy, and a restore removes it - Always encrypted: the task refuses to run without a key, since the file holds every login and both keys of the instance
The Copy
createConfigCopy({ includeHistory }) in src/services/config/database-copy.ts:
createDatabaseSnapshot()writes a consistent copy withVACUUM INTO, the same one Download database usesopenCopy()opens it with a secondPrismaClient, apart from the live oneDROPPED_TABLESare emptied (sessions, verifications, caches), and without Include the history theHISTORY_TABLEStoo- The keys row is written, then
VACUUMgives the freed space back
runConfigBackup() in src/lib/runner/config-runner.ts streams the copy through gzip and the encryption stream and uploads config-backups/config_backup_<timestamp>.db.gz.enc. Its .meta.json carries kind: "database", appVersion, the usual encryption block and, like the metadata of a job, timestamp and trigger, which system-task-service passes in (Scheduler without one). The runner adds the file to the storage listing cache right after the upload, so the Backups page shows it without a scan. The retention keeps the newest files named config_backup_*, older JSON files included, deletes the others by the path of the listing and drops them from the cache.
Restore of a Copy
A restore has two steps, src/services/config/restore-flow.ts:
- Check:
checkUploadedBackup()orcheckStoredBackup()decrypt and unpack the file into a temp file withopenBackupFile().isDatabaseCopy()reads the SQLite header, andinspectDatabaseCopy()runsPRAGMA integrity_check, compares_prisma_migrationswithprisma/migrationsso a copy of a newer version is refused, reads the keys row and counts what the copy holds. A copy without keys row, like a download of the database, is refused when its secrets do not open with the key of this instance. The checked file waits inpending-restores.tsfor 30 minutes under a token bound to the user who checked it - Apply:
applyCheckedRestore()refuses while a run isRunning.stageDatabaseRestore()drops triggers and views, encrypts the secrets again when the keys row names other keys, removes the keys row and the sign-ins, writes an audit entry into the copy, lays it beside the live database asrestore-pending.dband callsrestartSoon(), which ends the process after the answer went out - Swap:
scripts/apply-pending-restore.jsruns beforeprisma migrate deploy, indocker-entrypoint.shand inpnpm dev. It moves the live database with its-wal,-shmand-journalfiles aside asdbackup.db.before-restore*and renames the copy into place. On a failure it puts everything back and keeps the copy asrestore-failed-<time>.db, so the next start does not try again
Encrypting Again
rekeyCopy() in copy-rekey.ts runs when the keys of the copy differ from the ones of this instance. Every value DBackup encrypts has the form iv:authTag:data in hex, and AES-GCM opens only a value that really is one, so the copy is searched rather than listed: every TEXT column of every table, and every string inside a JSON value, also JSON held in such a string, like the config of a connection in Recently deleted. Tables that never hold a secret are skipped for speed. Second factors are encrypted by better-auth with BETTER_AUTH_SECRET and go through symmetricDecrypt and symmetricEncrypt, those of the users in Recently deleted as well.
A new table needs nothing here. A table that can never hold a secret may join the skip list.
Routes
| Route | Who | What |
|---|---|---|
POST /api/settings/config-backup/restore | SuperAdmin session | Checks an uploaded file, up to the 10 MB the middleware passes |
POST /api/settings/config-backup/restore/destination | SuperAdmin session | Checks a backup at a destination, read on the server |
POST /api/settings/config-backup/restore/apply | SuperAdmin session | Restores a checked backup by its token |
POST /api/setup/restore, /api/setup/restore/apply | Anyone while no account exists | The same on the sign-up page of a new instance |
requireConfigRestorer() in src/lib/server/config-restore-guard.ts guards the routes in Settings. A 413 answers an upload over CONFIG_UPLOAD_MAX_BYTES before its body is read, and a missing key the usual 422 of keyRequiredResponse().
Import of Older Files
Configuration backups of versions before the copy are JSON files, config_backup_*.json.gz.enc. A check recognises them by their content and the same two steps restore them, as a whole, without a restart. The Backups page still restores them in parts through restoreFromStorageAction.
importConfiguration() in src/services/config/import.ts restores the parts the caller picked in one transaction, in the order the links need. Each part lives in a module of its own:
| Module | Restores |
|---|---|
import-connections.ts | Settings, saved logins, connections, encryption keys |
import-jobs.ts | Jobs, their destinations and the channels they name |
import-users.ts | Groups, users with their sign-ins, API keys, sign-in providers |
import-history.ts | Runs, audit log, notifications and storage history |
import-context.ts holds what they share: the transaction, the maps from an ID of the file to the ID here, the IDs known to exist and the notes.
Matching and Links
| Scenario | Resolution |
|---|---|
| Same ID exists | Update existing record |
| Same name exists under another ID | Update that record and keep its ID, every link follows through the ID maps |
| New ID | Create new record |
| Link to a record neither in the file nor here | Drop the link and add a note |
A foreign key must never stop a restore. Every link is checked before the write, and the record comes back without what is missing:
| Missing | What the restore does |
|---|---|
| Retention policy of a job destination | retentionPolicyId null and retention keeps everything, so the default policy cannot remove backups |
| Naming template or schedule preset of a job | The link is null, the job uses the default names or its own schedule |
| Encryption key of a job | The key is null and the job paused, so it does not back up unencrypted |
| Database connection of a job | sourceId is null |
| Job or connection of a destination, user of an API key | The row is left out |
| Second factor of a user | twoFactorEnabled or passkeyTwoFactor is false |
importConfiguration() returns { notes }, one sentence per kind of change. restoreFromStorage() writes them to the log of the run as warnings, and Restore from a file shows them in a toast. A table the export learns later needs its links checked the same way.
Service Layer
The copy and its restore live in src/services/config/: database-copy.ts, copy-inspect.ts, copy-rekey.ts, open-backup.ts, pending-restores.ts, restore-staging.ts and restore-flow.ts. config-service.ts is the facade of the JSON files of older versions:
class ConfigService {
parseBackupFile(filePath: string, metaFilePath?: string, rawKeyHex?: string): Promise<AppConfigurationBackup>;
import(data: AppConfigurationBackup, strategy: "OVERWRITE", options?: RestoreOptions): Promise<ImportResult>;
restoreFromStorage(storageConfigId: string, file: string, decryptionProfileId?: string, options?: RestoreOptions): Promise<string>;
}Security Considerations
Who Restores
A restore writes users, groups, API keys and sign-in providers, so it could make anyone a SuperAdmin. The routes in Settings and restoreFromStorageAction check settings:write first and then refuse anyone who is no SuperAdmin, the routes also every API key. The routes of the sign-up page answer only while no account exists, the moment anyone who reaches the page may create the first account too. Taking a config backup stays with settings:write.
The File
The file holds every secret of the instance and both of its keys, so its encryption key is as sensitive as the server itself. Keep the recovery kit of that key apart from the backups.