Skip to content

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 ──▶ Destination

Core Concepts ​

  1. Complete by construction: the copy is the database, templates, folders of file jobs, second factors and passkeys included
  2. Keys travel with it: a row configBackup.copyKeys in SystemSetting of the copy holds ENCRYPTION_KEY, BETTER_AUTH_SECRET, the version and the time. It only ever exists in a copy, and a restore removes it
  3. 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:

  1. createDatabaseSnapshot() writes a consistent copy with VACUUM INTO, the same one Download database uses
  2. openCopy() opens it with a second PrismaClient, apart from the live one
  3. DROPPED_TABLES are emptied (sessions, verifications, caches), and without Include the history the HISTORY_TABLES too
  4. The keys row is written, then VACUUM gives 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:

  1. Check: checkUploadedBackup() or checkStoredBackup() decrypt and unpack the file into a temp file with openBackupFile(). isDatabaseCopy() reads the SQLite header, and inspectDatabaseCopy() runs PRAGMA integrity_check, compares _prisma_migrations with prisma/migrations so 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 in pending-restores.ts for 30 minutes under a token bound to the user who checked it
  2. Apply: applyCheckedRestore() refuses while a run is Running. 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 as restore-pending.db and calls restartSoon(), which ends the process after the answer went out
  3. Swap: scripts/apply-pending-restore.js runs before prisma migrate deploy, in docker-entrypoint.sh and in pnpm dev. It moves the live database with its -wal, -shm and -journal files aside as dbackup.db.before-restore* and renames the copy into place. On a failure it puts everything back and keeps the copy as restore-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 ​

RouteWhoWhat
POST /api/settings/config-backup/restoreSuperAdmin sessionChecks an uploaded file, up to the 10 MB the middleware passes
POST /api/settings/config-backup/restore/destinationSuperAdmin sessionChecks a backup at a destination, read on the server
POST /api/settings/config-backup/restore/applySuperAdmin sessionRestores a checked backup by its token
POST /api/setup/restore, /api/setup/restore/applyAnyone while no account existsThe 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:

ModuleRestores
import-connections.tsSettings, saved logins, connections, encryption keys
import-jobs.tsJobs, their destinations and the channels they name
import-users.tsGroups, users with their sign-ins, API keys, sign-in providers
import-history.tsRuns, 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.

ScenarioResolution
Same ID existsUpdate existing record
Same name exists under another IDUpdate that record and keep its ID, every link follows through the ID maps
New IDCreate new record
Link to a record neither in the file nor hereDrop 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:

MissingWhat the restore does
Retention policy of a job destinationretentionPolicyId null and retention keeps everything, so the default policy cannot remove backups
Naming template or schedule preset of a jobThe link is null, the job uses the default names or its own schedule
Encryption key of a jobThe key is null and the job paused, so it does not back up unencrypted
Database connection of a jobsourceId is null
Job or connection of a destination, user of an API keyThe row is left out
Second factor of a usertwoFactorEnabled 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:

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

Released under the GNU General Public License. | Privacy · Legal Notice