Skoolsewa - School Management System Docs
Developer ResourcesBackup

Backup Features and Flows

Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for backup.

Backup Features and Flows

1. Documentation Evidence

Source TypeFiles or DocsWhat Was Extracted
Backend/docs/developer/backup/backend, and directly: every service under apps/api/src/modules/backup/shared/, admin/*/, workers/*.ts, including workers/backup-restore.processor.ts and workers/restore-runner.tsService behavior, side effects, persistence, state machines, and exactly what the restore executor does and does not do.
API/docs/developer/backup/api, and directly: admin/*/*.controller.ts filesRoute surface, actors, auth, response-visible behavior.
Schemapackages/db/src/schema/backup/*.tsEnums, constraints, and the floors retention enforces.
Route ground truthapps/api/test/structure/structure.baseline.jsonConfirmed the 24 routes documented here, all under admin/system/*.
Envapps/api/src/config/env.validation.tsDefaults for schedule, retention counts, timeouts, and the restore/replication/upload switches (BACKUP_ARTIFACT_HMAC_KEY_PATH, BACKUP_UPLOAD_MAX_MB, BACKUP_UPLOAD_MAX_RETAINED, INTERNAL_CLIENT_IP_TOKEN).

2. Feature Summary

FieldValue
Modulebackup
SubmoduleN/A
Primary user valueAn operator can prove the database is being protected, recover disk space automatically, get an off-site copy without babysitting it, catalogue an archive from another deployment, and genuinely put the database back the way it was after a mistake — with an automatic, linked pre-restore safety dump and post-restore row-count verification (see 5.4). Uploaded files and the MongoDB audit log ride along incrementally, at a fraction of the disk of a full copy each time, and a restore additively merges the audit-log copy back into the live log without touching Postgres data. While a restore replaces the database, the API refuses non-operator traffic behind a traffic lock the restore engages and lifts itself; when a restore fails, the lock stays engaged until an operator has checked the database and releases it from the Backups page.
ActorsSuperadmin (every route requires superadmin-level permissions), and worker/system (cron schedulers, BullMQ processors, the detached restore-runner process).
Main entry pointsadmin/system/backups* (including .../upload) and admin/system/restores* HTTP routes; BackupScheduleScheduler's nightly cron; BackupRetentionScheduler's daily cron; BackupSweepScheduler's 5-minute cron.
Main outputsbackup_run/backup_restore rows, manifest.json/settings.json/restore-state.json/maintenance.json files, a streamed pg_dump download, an off-host rsync copy.
Related docsAPI, Backend.

3. Actor Matrix

ActorCan DoCannot DoAuth RequirementNotes
SuperadminList/create/pin/prune/download backups; read and write backup settings; request a restore; poll live restore state; force-clear a stuck restore; read the restore traffic lock and release one a failed restore left engagedAnything as a regular admin account — Backup, BackupDownload and BackupConfigure are all withheld from adminAdmin JWT + Backup_*/BackupDownload_READ/BackupConfigure_UPDATEThe only human actor this module recognises.
Admin (regular)Nothing directly in this module's backup surfacesEverything above—Sees 403 on every admin/system/backups* and admin/system/restores* route.
Worker/system (schedulers)Create a scheduled backup nightly; enqueue the daily retention sweep; fail stalled runs and stalled replications every 5 minutesSkip the same guards a human request would hit — the scheduled path shares BackupRunCreateService and BackupRetentionService with the admin-triggered pathIn-process cron (SchedulerRegistry/@Cron), no HTTP—
Worker/system (BullMQ)Execute backup.run, backup.prune, backup.replicate, backup.restore (the last by taking an inline safety dump, linking it to the restore, then spawning and supervising the detached runner)Verify anything beyond row countsBullMQ worker (concurrency: 1 per queue)See 5.5 and 5.4 for what each queue actually does.
Restore runner (workers/restore-runner.ts)Fence the app DB role, run pg_restore --clean --single-transaction, verify row counts against the manifest at the verifying stage, heartbeat a state file, unfenceTake the safety dump itself (that is the processor's job, before the runner is even spawned), or verify anything beyond row countsDetached process (setsid), spawned by BackupRestoreProcessor on QueueName.BACKUP_RESTORESee 5.4.
Every other clientNothing in this moduleAny route here; and while a restore's traffic lock holds, any non-operator route anywhere in the API (503 SYSTEM_MAINTENANCE_ACTIVE)—See 5.7 for what stays reachable.

4. Capability Matrix

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
List backupsAdminSuperadminGET admin/system/backupsbackup_run—API 8.1
Run a manual backupAdminSuperadminPOST admin/system/backupssettings, disk free spacebackup_run, outbox_eventsAPI 8.2
Read backup settingsAdminSuperadminGET admin/system/backups/settingssettings.json, env, backup_run (health)—API 8.3
Update backup settingsAdminSuperadminPUT admin/system/backups/settingscurrent settingssettings.json, possibly reloads the cron jobAPI 8.4
View backup detailAdminSuperadminGET admin/system/backups/{publicId}backup_run—API 8.5
Pin / unpin a backupAdminSuperadminPATCH admin/system/backups/{publicId}backup_runbackup_run.pinnedAPI 8.6
Prune a backup nowAdminSuperadminDELETE admin/system/backups/{publicId}backup_run, backup_restore (floors)outbox_events, later backup_run + filesystemAPI 8.7
Download a backupAdminSuperadminGET admin/system/backups/{publicId}/downloadbackup_run, filesystemMongoDB AuditLog (must succeed)[API 8.8](/docs/developer/backup/api#88-get-apiadminsystembackupspublicid download)
Upload and catalogue an archiveAdminSuperadminPOST admin/system/backups/uploadsettings.json (retained cap), uploaded manifest/signaturebackup_run (kind:'uploaded')API 8.14
Request a restoreAdminSuperadminPOST admin/system/backups/{publicId}/restoreeverything preflight checksbackup_restore, restore-state.json, maintenance.json, outbox_events, and (inline, before the runner spawns) a new pre_restore_safety backup_runAPI 8.9
Poll live restore stateAdminSuperadminGET admin/system/restores/{publicId}/liverestore-state.json only—API 8.10
Force-clear a stuck restoreAdminSuperadminPOST admin/system/restores/{publicId}/force-clearbackup_restore, restore-state.json, lock statebackup_restore, maintenance.json (only when no reconciliation is owed)API 8.11
Read the restore traffic lockAdminSuperadminGET admin/system/restores/lockmaintenance.json/Redis/cache, backup_restore, restore-state.json—API 8.12
Release a traffic lock a failed restore leftAdminSuperadminPOST admin/system/restores/lock/releasebackup_restore, restore-state.json, lock statemaintenance.json, Redis mirror, Mongo audit logAPI 8.13
Download a backup's uploaded filesAdminSuperadminGET admin/system/backups/{publicId}/download/uploadsbackup_run, filesystemMongoDB AuditLog (backup.download_uploads, must succeed)API 8.23
Read the key custody stateAdminSuperadminGET admin/system/backup-keykey file, backup_run (counts)—API 8.16
Generate the signing keyAdminSuperadmin + passwordPOST admin/system/backup-key/generatekey filekey fileAPI 8.17
Import a key from another hostAdminSuperadmin + passwordPOST admin/system/backup-key/importkey filekey file (retired set)API 8.18
Rotate the signing keyAdminSuperadmin + passwordPOST admin/system/backup-key/rotatekey filekey file (old key → retired)API 8.19
Resolve backup signaturesAdminSuperadminPOST admin/system/backup-key/resolve-signatureskey file, artifact.sig on diskbackup_run.signature_state, .signing_key_fingerprintAPI 8.20
Prune a retired keyAdminSuperadmin + passwordDELETE admin/system/backup-key/retired/{fingerprint}key file, backup_run countskey fileAPI 8.21
Create a scheduled backupWorkerSystemNightly cron (BackupScheduleScheduler)settings, backup_run (tier resolution)backup_run, outbox_eventsBackend 7.2
Sweep retentionWorkerSystemDaily cron (BackupRetentionScheduler) → backup.prune {reason:"scheduled"}settings, backup_run per bucketfilesystem (rm), backup_runBackend 7.3
Recover stalled runs/replicationsWorkerSystem5-minute cron (BackupSweepScheduler)backup_run (running/pending)backup_run (→ failed)Backend 7.4
Replicate off-hostWorkerSystembackup.replicate job (enqueued at run completion)backup_run, filesystembackup_run replication columns, remote hostBackend 7.9
List background job failuresAdminSuperadminGET admin/system/backups/failuresdead letters on QueueName.BACKUP/QueueName.BACKUP_REPLICATE—API 8.24
Retry a failed pruneAdminSuperadminPOST admin/system/backups/failures/{publicId}/retry-prunedead-letter rowfresh backup.prune jobAPI 8.25
Retry a failed replicationAdminSuperadminPOST admin/system/backups/failures/{publicId}/retry-replicationdead-letter rowfresh backup.replicate jobAPI 8.26

5. User-Facing Flows

5.1 Take a backup — manual ("Run now")

Summary

An operator wants a backup right now — before a risky migration, before a release, or just to confirm the feature works. They click "Run now" and see the row move from queued to running to completed on the list.

Preconditions

  • backupEnabled is true in settings (defaults true).
  • No other backup is currently queued or running.
  • Free disk space is at least minFreeDiskMb plus 1.2× the last completed dump's size (or just minFreeDiskMb for the very first backup ever taken).
  • Superadmin session with Backup_CREATE.

Main Flow

StepActor/SystemActionResultSource
1AdminPOST admin/system/backups, optionally with includeUploads/note201, row queuedBackupAdminController.create
2BackendValidates enabled/no-run-in-progress/disk space, inserts the row and an outbox event in one transactionRow exists, job scheduledBackupRunCreateService.createRun
3WorkerOutbox dispatcher relays to QueueName.BACKUP (~5s), BackupRunHandler claims the rowRow runningBackupRunHandler.claim
4WorkerOpens a REPEATABLE READ snapshot, runs pg_dump --format=custom --snapshot=<id>, optionally tars the upload rootTwo artifact files on disk (or one, if uploads excluded)BackupManifestService.captureAndRun, BackupArtifactService
5WorkerWrites manifest.json before marking the row completeCatalog is durable on disk even if the process dies right afterBackupManifestService.writeManifestFile
6WorkerUpdates the row to completed with bytes/sha256/manifest; enqueues backup.replicate in the same transaction if configuredRow completed, appears on the listBackupRunHandler.run

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Backups disabledbackupEnabled=falseReject before any row is created503 BACKUP_DISABLED
Another run in progressA row is queued or runningReject synchronously — a second click is refused even before the first has been claimed by a worker409 BACKUP_ALREADY_RUNNING
Low diskFree space below the computed floorReject synchronously503 BACKUP_INSUFFICIENT_DISK_SPACE
pg_dump fails or times outTransient DB error, disk full, or exceeds dumpTimeoutMinutesRow marked failed; only a sanitised excerpt is stored, never raw stderrBACKUP_DUMP_FAILED / BACKUP_DUMP_TIMED_OUT
API crashes between artifact write and row completionProcess killed mid-dumpmanifest.json may already exist with no matching rowreconcileFromDisk (runs at boot) rebuilds the row from the manifest — this is the exact reason the manifest is written before the row is marked complete
API crashes with the row stuck runningWorker process dies without a clean failureRow sits running indefinitelyBackupSweepScheduler marks it failed within dumpTimeoutMinutes + 5 minutes

5.2 Take a backup — scheduled (nightly)

Summary

No human is involved. Every night at scheduleCron/scheduleTimezone (default 0 2 * * * Asia/Kathmandu), the system decides which grandfather-father-son (GFS) tier is due and creates that backup automatically.

Preconditions

  • backupEnabled is true.
  • Same disk-space and no-run-in-progress checks as the manual path — the scheduler calls the identical BackupRunCreateService.createRun.

Main Flow

StepActor/SystemActionResultSource
1SchedulerCron ticks in scheduleTimezone—BackupScheduleScheduler (SchedulerRegistry, not @Cron, because the schedule is admin-editable)
2SchedulerResolves the tier: the most significant period with no completed run yet — month, then ISO week, then daydaily/weekly/monthly chosenresolveTier / computeBackupTierPeriodBoundaries
3SchedulerCalls the same createRun({kind:"scheduled", tier}) the manual path usesRow queued, kind='scheduled'shared with 5.1 from here

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Tier already has a completed run this periode.g. a monthly already completed this calendar monthFalls through to the next-most-significant period still dueweekly, then daily
Every period already coveredRare — would mean a daily also already ran todayresolveTier always returns at least daily as the floor; a second daily in one day is only possible via a retried tick after a failure—
Tick throwsAny error in runTickLogged and swallowedThe next night's tick is unaffected
Backups disabled at tick timebackupEnabled=falseSame as 5.1's disabled branch, caught by runTick's try/catchLogged, no row created, no exception propagates
Tier assigned before success is knownThe row is inserted queued with a tier already setThis is deliberate — chk_backup_run_tier_matches_kind requires a non-null tier on every scheduled row at INSERT, before the dump has even startedA failed scheduled run still "used" its tier slot for that tick; the next tick reassigns it since a failed run does not count as completed

5.3 Retention pruning

Summary

Old backups disappear on their own, keeping disk usage bounded, while always leaving enough history to recover from. An operator never manually deletes backups to manage disk space unless they want to remove one specific run early.

Preconditions

  • At least one bucket (daily/weekly/monthly/manual/pre_restore_safety) has more completed, unpinned rows than its retain* count.

Main Flow — scheduled sweep

StepActor/SystemActionResultSource
1SchedulerDaily at 03:00, enqueues backup.prune {reason:"scheduled"} directly (the documented outbox exemption — a cron tick with no accompanying database write)Job on QueueName.BACKUPBackupRetentionScheduler.enqueueSweep
2WorkerFor each of the 5 buckets, reads completed+unpinned rows newest-first, slices off everything past the retain countCandidate list per bucketBackupRetentionService.sweep
3WorkerFor each candidate, re-checks the 3 floors, then deletes the artifact directory and marks the row prunedDisk reclaimed; row survives as historyassertPrunable, pruneRun
4WorkerSeparately, for any remote_only run whose replication is verified and is not the overall-newest backup, deletes only the heavy local artifacts (keeps manifest.json)localArtifactPresent=false, row stays completedsweepRemoteOnlyLocalCopies

Main Flow — manual delete

StepActor/SystemActionResultSource
1AdminDELETE admin/system/backups/{publicId}Floors checked synchronously; 409 immediately if ineligibleBackupAdminService.remove
2BackendEnqueues backup.prune {reason:"manual", backupRunPublicId} through the outbox—same
3WorkerRe-checks the floors (they can change between click and pickup) then prunes exactly like the scheduled pathRow prunedBackupPruneHandler.prune

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Pinned row is a candidatepinned=trueSkipped, logged (scheduled) or 409 (manual)BACKUP_PINNED_CANNOT_DELETE
Row is the newest in its own buckete.g. the only daily row todayProtected regardless of the retain countBACKUP_LAST_REMAINING_CANNOT_DELETE
Row is the newest completed backup overallAcross every bucketProtected — the site must always have at least one restorable backupsame code
Row is the safety dump of the most recent restorekind='pre_restore_safety' and matches the latest backup_restore.safety_backup_run_idProtected — BackupRestoreProcessor.takeSafetyDump writes safety_backup_run_id onto the restore's own row immediately after the dump completes, so this comparison is genuinely satisfiable (see 10)same code
One floor blocks one row during a sweepAny of the above, during the scheduled sweepThat row is skipped and logged; the rest of the sweep continuesNever stops the whole sweep
A remote_only run is the overall-newestWould otherwise have its local copy reclaimedLocal copy is kept regardless — a restore that has to pull gigabytes over SSH during an incident is a restore that will not happen—
Retried delete requestremove() called twice for an already-pruned rowpruneRun is a no-op for a non-completed rowIdempotent, not an error

5.4 Restore the live database

Summary

An operator needs to undo something catastrophic — replace the entire live database with a known-good backup. This is the single most irreversible operation in the product, and it is the only flow in this module gated by two independent switches: a grantable permission and a deploy-time environment variable that cannot be granted from inside the running application.

Read this before anything else in this section: requesting a restore genuinely replaces the database — preflight validates everything, maintenance engages, an automatic pre-restore safety dump is taken, linked to this specific restore, and must succeed before anything destructive runs, existing connections are drained before being terminated, a detached process runs pg_restore --clean --single-transaction against the confirmed dump, and row counts are verified against the manifest afterward. What still is not automated: verification beyond row counts (content, permissions), and a one-click "undo" that automatically resubmits the linked safety dump. See 10 below for exactly what is and is not covered.

Preconditions

  • BACKUP_RESTORE_ENABLED=true at the deploy level (default false — restore is meant to be armed deliberately, after an operator has already seen a backup complete and downloaded one, never on day one).
  • BACKUP_ADMIN_DATABASE_URL configured — a separate database role from the application's own DATABASE_URL, one permitted to terminate backends and drop objects. The web application's own credential must never carry that authority, so that a SQL injection anywhere in the API cannot inherit it.
  • The application's own DB role (DATABASE_URL's role) must not be a Postgres superuser. This is checked at preflight, before any write: the restore fences the application out with ALTER ROLE <app> CONNECTION LIMIT 0 so pg_restore --clean is not contending for ACCESS EXCLUSIVE locks against live traffic — and PostgreSQL does not enforce rolconnlimit against a role with rolsuper. If the application ever connected as a superuser, the fence would silently do nothing.
  • Superadmin session with Backup_RESTORE.
  • The source backup is completed with its artifact still present locally, its recorded size and sha256 matching the file on disk, and its schemaMigrationTag equal to the running schema's tag: the live database's latest drizzle.__drizzle_migrations.hash, read at preflight with the same query a backup records its own tag with. A null or "unknown" tag on either side refuses rather than compares (no override — a mismatch means the running code and the restored schema disagree, which surfaces later as a missing column somewhere unrelated).
  • No other restore is non-terminal, and no backup dump is currently queued/running.

Main Flow

StepActor/SystemActionResultSource
1AdminTypes the live database name, checks both "I understand" boxes, submits POST .../restore—Admin UI + RestoreBackupDto
2BackendRuns 8 sequential preflight guards (feature flag, superuser check, source exists, acknowledgements, typed name matches current_database(), source restorable, schema tag matches, size+sha256 match on disk)All pass, or the whole request is refused with nothing changedBackupRestorePreflightService.assertRestoreAllowed
3BackendInserts a backup_restore row (queued) and an outbox event, one transactionRow existsBackupRestoreAdminService.requestRestore
4BackendEngages the traffic lock (maintenance.json + Redis) immediately, not deferred to a worker, and before the state file is written. A refused engage closes the row and answers 409Customer traffic is refused from this instant, closing the window where writes would otherwise be destroyed on the eventual restoreMaintenanceService.engageIf
5BackendWrites restore-state.json (stage:"queued", including sourceArtifactDir the runner will need)The live-poll endpoint has something to read immediately, and the runner has everything it needs once it startsBackupStateFileService.writeRestore
6BackendReturns 202 RestoreLiveStateDtoAdmin sees "Restore accepted"—
7WorkerOutbox dispatcher relays backup.restore to QueueName.BACKUP_RESTORE; BackupRestoreProcessor first reads the restore's row and skips the job, writing nothing, when the row is missing or already closed (a force-cleared restore never runs); it then confirms the state file matches this restore, reads the effective settings once, then — before spawning anything — takes an inline, awaited pre-restore safety dump: a real kind:'pre_restore_safety' backup, run synchronously in the same process via BackupRunHandler.run(), not enqueued. Its id is written into the state file as safetyBackupPublicId before the dump runsState file moves to stage:"safety_dump"; if the dump does not reach completed, the restore aborts here and maintenance stays engagedworkers/backup-restore.processor.ts (takeSafetyDump)
8WorkerOnce the safety dump is confirmed completed, links it to the restore: UPDATE backup_restore SET safety_backup_run_id, only while the row is still open. A row force-cleared during the dump skips the job here, before the runner spawns; a row that is gone aborts the restore here too, rather than proceeding with a link it could never recordbackup_restore.safety_backup_run_id setworkers/backup-restore.processor.ts (takeSafetyDump)
9WorkerSpawns restore-runner.ts <restorePublicId> as a detached process — deliberately not an in-process BullMQ worker, because pg_restore --clean holds ACCESS EXCLUSIVE on every table including admin_sessions, which JwtStrategy reads on every authenticated request — with the effective restoreTimeoutMinutes/restoreDrainSeconds injected into its environmentState file advances draining → restoring → verifying → completed/failed (reconciling remains declared but never assigned)workers/backup-restore.processor.ts (spawnRunner)
10RunnerFences the application's DB role (ALTER ROLE ... CONNECTION LIMIT 0), then drains: polls open connections for that role once a second, heartbeating progress, until either none remain or restoreDrainSeconds elapses — only then terminates whatever is left with pg_terminate_backendNew connections blocked immediately; existing ones get a grace window to finish in-flight work before being cutworkers/restore-runner.ts
11RunnerRuns pg_restore --clean --if-exists --single-transaction --no-owner --no-privileges against the confirmed dump, bounded by restoreTimeoutMinutes, then — still behind the connection fence — gives every pending outbox row a new dispatch_key, so a restored row cannot reuse the BullMQ job id of a finished job still in RedisDatabase replaced, or left untouched on failure; a re-key failure is reported as BACKUP_RESTORE_OUTBOX_REKEY_FAILEDworkers/restore-runner.ts, workers/rekey-restored-outbox.ts
12RunnerMoves to stage:"verifying" and runs verifyRowCounts(): reads the source backup's own manifest.json and compares every listed table's recorded row count against a live count(*) on the just-restored database, excluding RESTORE_VERIFICATION_EXCLUDED_TABLES (session tables, backup_run, backup_restore, outbox_events, drizzle_migrations — tables the restore and the safety dump themselves are expected to change)Any mismatch throws, reported as BACKUP_RESTORE_VERIFICATION_FAILED specifically (not the generic failure code)workers/restore-runner.ts (verifyRowCounts)
11RunnerHeartbeats the state file every 5 seconds throughout, then always releases the connection fence in a finally block, releasing it before publishing stage:"completed" so the supervisor's own DB query is not itself blocked by the fence—workers/restore-runner.ts
13WorkerBackupRestoreProcessor reads the terminal stage. When pg_restore may have committed — completed, a failure during verifying or in the outbox re-key, or an abandonment at or after restoring — it first closes every other open backup_restore row the dump brought back (failed, BACKUP_RESTORE_ABANDONED), then reconciles the accounting integration and records reconciled or reconcile_failed on the lock. It updates its own backup_restore row while that row is still open (it usually no longer exists — pg_restore --clean replaced it with the dump's own copy). On completed with a successful reconciliation it lifts the lock, only if the lock still names this restoreCustomer traffic resumes; on failure or a failed reconciliation, the traffic lock stays engaged until an operator has inspected the database and released it (5.7)BackupRestoreProcessor.finish/fail
—Not builtVerifying anything beyond row counts (permissions, content) — the structured backup_restore.verification shape is never written even though the row-count comparison itself runs; and a one-click "undo" that automatically selects the linked safety dump as the new restore sourceA restore that reaches completed is trusted on row counts alone; undoing a bad restore still means an operator manually submitting the (now directly discoverable, via safetyBackupPublicId) safety dump as a fresh restore requestbackup-restore.ts, workers/restore-runner.ts

Why --single-transaction is load-bearing

Probed directly, not assumed: restoring a truncated dump with --single-transaction left the target database completely untouched — the whole restore rolled back as one unit. Restoring the same truncated dump without the flag emptied the table and the original rows were gone. The automatic pre-restore safety dump is a second, independent safety net on top of this — the way back if the chosen backup itself turns out to be wrong, not just if pg_restore fails partway. This is why --single-transaction is a fixed part of pg_restore's argv in the runner script, never a configurable option.

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Restore not armedBACKUP_RESTORE_ENABLED=false, or BACKUP_ADMIN_DATABASE_URL unsetRefused before any write503 BACKUP_RESTORE_DISABLED
App role is superuserrolsuper=true on the DATABASE_URL roleRefused — the connection fence would be a no-op409 BACKUP_RESTORE_APP_ROLE_IS_SUPERUSER
Wrong database name typedDoes not match current_database()Refused — the confirmation dialog is a speed bump, this check is the actual control400 BACKUP_RESTORE_CONFIRMATION_MISMATCH
Schema version mismatch or unknownBackup's schemaMigrationTag differs from, or either side is missingRefused, no override409 BACKUP_RESTORE_SCHEMA_VERSION_MISMATCH
Artifact tampered or corruptedSize or sha256 mismatch on diskRefused before any restore attempt409 BACKUP_SIZE_MISMATCH / BACKUP_CHECKSUM_MISMATCH
Another restore already in flightA backup_restore row is non-terminalRefused fast, before the partial unique index would surface a raw 23505409 BACKUP_RESTORE_ALREADY_ACTIVE, details.blockingRestorePublicId naming the open restore
A previous restore still owes a reconciliationThe traffic lock names another restore with reconciliation pending or reconcile_failed, or maintenance.json is unreadableRefused before any row is written: engaging would overwrite the marker that keeps the lock from lifting over integration keys revoked after the dump. The check runs again inside MaintenanceService's critical section; a marker that appears after the row is written closes that row failed with BACKUP_RESTORE_ALREADY_ACTIVE, and the lock and restore-state.json stay as they were409 BACKUP_RESTORE_ALREADY_ACTIVE, details.blockingRestorePublicId naming the lock's restore (null for an unreadable file). Wait for it to settle, restart the API to retry a failed reconciliation, or release an unreadable lock first
A backup dump is runningbackup_run queued/runningRefused — restoring while a snapshot transaction is open is not safe409 BACKUP_RESTORE_RUN_IN_PROGRESS
Pre-restore safety dump fails or does not reach completedDisk full, pg_dump error, or the dump insert itself throwsRestore aborts before pg_restore ever runs; the traffic lock stays engaged until released (5.7)BACKUP_RESTORE_FAILED
pg_restore succeeds but row counts do not match the manifestCorruption, or an unexpected schema drift the tag check missedThe database is populated but wrong — treated as more dangerous than an outright failureBACKUP_RESTORE_VERIFICATION_FAILED
pg_restore succeeds but the pending outbox rows cannot be re-keyedThe UPDATE outbox_events on the admin connection throwsThe database is replaced and reconciled; a pending row may share a job id with a finished job in Redis, so its work may not run. The traffic lock stays engaged, but it gates only HTTP: the runner releases the connection fence as it exits and outbox relaying resumes at once. Run the re-key statement immediately (Re-keying the restored outbox by hand)BACKUP_RESTORE_OUTBOX_REKEY_FAILED
Runner never spawns, or dies mid-restorerestore-runner.ts fails to start, or its heartbeat goes stale past BACKUP_RESTORE_ABANDON_SECONDSBackupRestoreProcessor marks the row failed (BACKUP_RESTORE_ABANDONED), the traffic lock stays engaged. A runner that went silent at or after restoring may have committed, so the restore is reconciled as though it hadOperator inspects the server log, then releases the lock (5.7)
Restore accepted, then the operator needs to abandon itA runner that never spawned or was killedMarks a non-terminal row failed. Lifts the traffic lock when the restore never reached pg_restore (the state file for it stopped at queued, draining or safety_dump, or reports BACKUP_RESTORE_FAILED), or when the lock owes no reconciliation; otherwise the lock keeps its pending or reconcile_failed marker for the supervisor — or a restarted API — to settle. Refused while the state file names this restore's runner as a live process, and refused once the row is already terminalPOST .../force-clear; 409 BACKUP_RESTORE_ALREADY_ACTIVE / 409 BACKUP_RESTORE_NOT_FOUND
The API restarts during a restoreThe supervising job dies with the process (maxStalledCount: 0 fails it rather than re-running it); the detached runner carries onAt boot, BackupRestoreProcessor.resumeAfterRestart finds the lock still pending with the runner already spawned, and supervises the restore again from the state file until it settles; a lock left reconcile_failed is reconciled againThe restore settles as if the job had lived
Live-state poll during a restoreThe state file is being written concurrentlyThe endpoint reads the file directly and touches no table, so it stays answerable while pg_restore --clean holds ACCESS EXCLUSIVE locks and JwtStrategy would otherwise block reading admin_sessionsExercised by real restores now, not only designed for them

Re-keying the restored outbox by hand

The runner gives every pending outbox row a fresh dispatch_key right after pg_restore commits, while the connection fence still holds. When that step fails (BACKUP_RESTORE_OUTBOX_REKEY_FAILED), the runner still releases the connection fence on its way out, as it does on SIGTERM. The outbox dispatcher and its scheduler do not consult the traffic lock, which gates only HTTP, so relaying resumes as soon as the fence is released. A runner killed with SIGKILL between pg_restore committing and the re-key leaves the fence set only until the supervisor marks the restore abandoned at restoring after BACKUP_RESTORE_ABANDON_SECONDS. The supervisor then releases the fence itself, and relaying resumes with the pending rows still on their restored keys.

In either case the operator runs the same statement the runner runs, against the restored database, immediately:

UPDATE public.outbox_events SET dispatch_key = gen_random_uuid() WHERE status = 'pending';

Only pending rows are re-keyed; a dispatched row keeps its key, because report maintenance looks its live job up by it.

A row that moved to dispatched between the failure and the statement may have been relayed onto a job BullMQ still retained from before the restore, and its work may not have run. Check each such row by looking up the job with id outbox-<dispatch_key> on the row's target_queue. The application sets no BullMQ key prefix, so that job's Redis hash is bull:<target_queue>:outbox-<dispatch_key>; a job whose timestamps predate the restore is the retained job, not a relay of the restored row.

5.5 Off-host replication

Summary

An operator wants a copy of every backup somewhere other than the VPS the database lives on, so a lost or compromised host does not also mean a lost backup. This is opt-in, transport-hardened, and verified by reading the bytes back rather than trusting the transfer's own exit code.

Preconditions

  • BACKUP_REMOTE_ENABLED=true, BACKUP_REMOTE_TARGET (a user@host:/path), BACKUP_REMOTE_SSH_KEY_PATH and BACKUP_REMOTE_KNOWN_HOSTS_PATH all configured at deploy time.
  • remoteReplicationEnabled=true in settings, and storageMode set to both or remote_only.
  • At boot, BackupReplicationService refuses to start with replication enabled unless the configured rsync binary supports --secluded-args (rsync 3.2.3+) and the known-hosts file exists and is non-empty — both are asserted once, at startup, rather than discovered failing at 02:00.

Main Flow

StepActor/SystemActionResultSource
1WorkerOn dump completion, if storageMode !== 'local' and replication is enabled, enqueues backup.replicate in the same transaction that marks the run completedreplicationStatus='pending'BackupRunHandler.run
2WorkerRecomputes the run's relative artifact directory from publicId+createdAt — never trusts the artifact_dir column, which originates in an operator-editable manifest.json — and validates it against a fixed <yyyy>/<mm>/<uuid> patternRefuses before any transfer if the shape is wrongBackupReplicationService.replicate
3WorkerPushes the dump (and archive, if present) via rsync --secluded-args, excluding manifest.jsonHeavy artifacts land on the remote hostrsyncPush
4WorkerPulls the dump back to a local temp directory and hashes it locally — never trusts a remote-reported hash, and this is compatible with a write-only deploy key that cannot run remote commandsVerified byte-for-byteverifyByReadBack
5WorkerPushes manifest.json last, only once the heavy artifacts are verifiedA disaster-recovery-side reconcileFromDisk never sees a manifest naming a completed run whose dump has not fully arrivedrsyncPush (manifest-only pass)
6WorkerUpdates the row: replicationStatus='replicated', replicatedAt, remoteArtifactDirSettings' replication-health fields reflect success—

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Already replicatedDuplicate delivery of the same jobNo-op — nothing to replicate, nothing to retry—
No local artifact to replicateRun not completed, or already local-copy-reclaimedNo-op, logged—
Recomputed path does not match the stored/expected shapeTampered or unexpected manifest.jsonRefused before any rsync callMarked failed
Transport failureNetwork unreachable, auth failure, timeoutRow marked replicationStatus='failed' — the backup itself stays completed, since a dump that succeeded and could not be shipped off-box is still a usable backupBACKUP_REMOTE_UNREACHABLE, BACKUP_REPLICATION_FAILED
Remote disk fullrsync reports no spaceRow marked failed with a specific codeBACKUP_REMOTE_DISK_FULL
Read-back hash mismatchBytes arrived corrupted despite rsync reporting successTreated as a failure, same as a transport errorBACKUP_REPLICATION_VERIFY_FAILED
Replication stuck pendingA worker died mid-transferBackupSweepScheduler ages it to failed past BACKUP_REPLICATE_TIMEOUT_MINUTES + 5—
storageMode='remote_only', retention sweep runsRun is verified-replicated and is not the overall-newest backupLocal heavy artifacts deleted, manifest.json kept, localArtifactPresent=falsereconcileFromDisk still works, since the manifest never left
Remote-side retentionOld copies piling up on the backup hostDeliberately out of scope for this module — the backup host is expected to run its own retention/rotation; this feature only ever pushes, never prunes remotely—

5.6 Upload and catalogue an operator-supplied archive (SE-4)

Summary

An operator has a pg_dump archive from somewhere other than this deployment's own scheduled/manual dumps — moved from another environment, or pulled off a downed host — and wants it in the catalog so the existing restore flow can act on it. This never restores anything by itself; it only authenticates and files the archive away.

Preconditions

  • Superadmin session with BackupUpload_CREATE.
  • A current signing key held in the key file — see 5.8.
  • The archive, its manifest.json, and its artifact.sig were all produced by a deployment holding a key this one generated — the signature is the actual authentication, not the checksum inside the manifest. An imported key does not count here: on upload the caller supplies the archive, the signature and the key, which is three operands from one actor.
  • Fewer than BACKUP_UPLOAD_MAX_RETAINED completed uploaded backups already exist.

Main Flow

StepActor/SystemActionResultSource
1AdminSelects the three files (file, manifest, signature) and submits POST .../backups/uploadMultipart request sentAdmin UI + BackupUploadDto
2BackendStreams the dump to a .partial file, hashing as it writes; checks the retained cap; parses the manifest for schemaMigrationTag/databaseDumpSha256Corruption/shape checks pass, or refused immediatelyBackupUploadStorageEngine, BackupUploadAdminService
3BackendVerifies the HMAC signature over dumpSha256:manifestSha256 — the actual authentication controlProceeds only if the signature matchesBackupSignatureService.verify
4BackendFormat-checks the archive (pg_restore --list), renders its schema SQL, and scans it for constructs a restore must not executeRefused if a dangerous construct is foundscanArchiveSql
5BackendMoves the dump into a real artifact directory, writes a fresh, server-generated manifest and signature (never the operator's own manifest), inserts a backup_run row (kind:'uploaded', pinned:true, status:'completed')201, appears in the catalog immediately, restorable like any other completed backupBackupUploadAdminService.finalize

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
A required part is missingfile, manifest, or signature absentRefused before any processing400 BACKUP_ARCHIVE_UNREADABLE / BACKUP_UPLOAD_MANIFEST_INVALID / BACKUP_UPLOAD_SIGNATURE_INVALID
Manifest is malformed or missing required fieldsNot JSON, not an object, or missing schemaMigrationTag/databaseDumpSha256Refused400 BACKUP_UPLOAD_MANIFEST_INVALID
Transfer was truncated or corruptedDump's sha256 does not match the manifest's claimRefused before the signature is even checked409 BACKUP_CHECKSUM_MISMATCH
Signing not configured on this deploymentNo current key in the key fileRefused — fails closed rather than accepting an unauthenticated archive503 BACKUP_UPLOAD_SIGNING_NOT_CONFIGURED
The key store is unreadableThe key file exists and cannot be parsedRefused. A corrupt file is not an absent one, and skipping verification because the file is broken is the failure the branch exists to prevent422 BACKUP_KEY_STORE_UNREADABLE
Wrong key, or archive from an unrelated deploymentHMAC does not matchRefused409 BACKUP_UPLOAD_SIGNATURE_INVALID
Archive contains a refused SQL constructe.g. an unexpected SECURITY DEFINER functionRefused before cataloguing409 BACKUP_UPLOAD_TOC_REJECTED
Retained cap reachedBACKUP_UPLOAD_MAX_RETAINED completed uploads already existRefused409 BACKUP_UPLOAD_RETAINED_LIMIT_REACHED
Re-uploading an already-catalogued archiveOperator downloads a previously-uploaded row and re-uploads itSucceeds — the row is re-signed over its own fresh manifest each time, so a round trip does not break the signatureNew backup_run row, subject to the retained cap
Retention never touches an uploaded rowGFS sweep runskind:'uploaded' rows are excluded from RetentionBucket entirely, on top of being pinned:trueOnly an explicit admin DELETE removes one

5.7 Release a traffic lock a failed restore left engaged

Summary

A restore engages the traffic lock when it is accepted, marked reconciliation: "pending", and lifts it itself when it completes cleanly and has reconciled the restored database. When a restore fails — the safety dump does not complete, pg_restore fails, the restored row counts do not match the manifest, the runner is abandoned, or the integration reconciliation fails — the lock stays engaged on purpose: a half-known database is not put back in front of users until an operator has looked. The operator checks the database, then releases the lock from the Backups page. No route engages the lock by hand.

A runner that has exited is not the same as a restore that has settled. The restored rows hold every integration key revoked after the dump as active, so a restore that may have replaced the database reconciles it — a new change-feed epoch, every integration key revoked, every webhook paused — before the lock can be released, whether the restore completed or failed after pg_restore committed. The lock records where that stands in reconciliation, and release refuses while it is pending or reconcile_failed.

Preconditions

  • Superadmin session: Backup_READ to read the lock, Backup_RESTORE to release it.
  • No backup_restore row is queued, safety_dump, restoring or verifying.
  • The runnerPid recorded in restore-state.json is not a live process.
  • The lock's reconciliation is reconciled or absent (null in the DTO) — not pending, not reconcile_failed.

Where reconciliation comes from

ValueWritten byWhen
pendingBackupRestoreAdminService.requestRestore (MaintenanceService.engageIf)The restore is accepted and engages the lock — refused while the lock still owes another restore's reconciliation
reconciledBackupRestoreProcessor.reconcileIntegrationThe integration reconciliation succeeded after the database may have been replaced — the runner reported completed, failed during verifying, or was abandoned at or after restoring
reconcile_failedBackupRestoreProcessor.reconcileIntegrationThat reconciliation threw
absentBackupRestoreProcessor.failThe restore failed before pg_restore could commit — the processor failed it before spawning a runner, the safety dump failed, or the runner reported BACKUP_RESTORE_FAILED (draining or restoring, where --single-transaction rolled back). Nothing was replaced, so nothing is owed

Every write compares restorePublicId first: a lock re-engaged by a later restore is never marked by an earlier one.

Main Flow

StepActor/SystemActionResultSource
1AdminOpens the Backups page, which reads GET admin/system/restores/lockRestoreLockDto — active, the restorePublicId that engaged it, reason, engagedAt, engagedBy, reconciliation, blockingRestorePublicId, releasableBackupRestoreLockService.getLockState
2Admin UIShows that the API is still refusing non-operator traffic, which restore engaged the lock, and where its reconciliation stands; offers Release only when releasable is true—Admin UI
3AdminChecks the database: the restore's last live stage and message, the server log, and the linked safetyBackupPublicIdDecides the database can be put back in front of users—
4AdminClicks Release — POST admin/system/restores/lock/release—BackupRestoreAdminController.releaseLock
5BackendReads the lock file, past every cacheThe snapshot the write is compared againstMaintenanceService.readSnapshot
6BackendRefuses if any restore row is non-terminal, or if the runner pid in the state file is alive409 BACKUP_RESTORE_ALREADY_ACTIVE naming the blocking restore, lock untouchedBackupRestoreLockService.releaseLock
7BackendInside MaintenanceService's critical section, re-reads the lock file: not engaged → released: false; a different restorePublicId than step 5 saw → 409 BACKUP_RESTORE_ALREADY_ACTIVE; pending → 409 BACKUP_RESTORE_RECONCILE_PENDING; reconcile_failed → 409 BACKUP_RESTORE_RECONCILE_FAILEDLock untouched on every refusalMaintenanceService.disengageIf
8BackendWrites the disengaged lock — maintenance.json first, then the Redis mirror — in the same critical section—maintenance.service.ts
9BackendRecords backup.restore.lock_released (module system, resource the restorePublicId step 5 read) in the Mongo audit log; logs [backup] restore lock released (<restorePublicId>)200 { "released": true }ActivityRecordService.recordActivity
10SystemEach API process stops refusing traffic once its 1-second in-process cache of the lock expiresUsers reach the API againMaintenanceGuard

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
A restore is still in progressA backup_restore row is non-terminalRefused; the lock stays engaged409 BACKUP_RESTORE_ALREADY_ACTIVE with details.blockingRestorePublicId — wait for it, or force-clear it by that id if its runner has died
A restore runner is aliverestore-state.json names a live runnerPid, whichever restore it belongs toRefused; the message names the pid409 BACKUP_RESTORE_ALREADY_ACTIVE, details.blockingRestorePublicId is the restore in the state file
A restore is accepted while the release is in flightrequestRestore engages its own lock after the release checked for blockers. Possible only over a lock that owes no reconciliation: while the lock reads pending or reconcile_failed, or is unreadable, the submit itself is refused with 409 BACKUP_RESTORE_ALREADY_ACTIVEThe release compares restorePublicId at the write and refuses rather than wiping the new lock409 BACKUP_RESTORE_ALREADY_ACTIVE, details.blockingRestorePublicId is the new restore
Reconciliation not settledreconciliation is pending: the restore has not finished, or the API process supervising it diedRefused. A restarted API resumes supervising the restore at boot and settles it409 BACKUP_RESTORE_RECONCILE_PENDING
Reconciliation failedreconciliation is reconcile_failedRefused. Restarting the API retries the reconciliation at boot (BackupRestoreProcessor.resumeAfterRestart); on success the marker becomes reconciled and the release succeeds409 BACKUP_RESTORE_RECONCILE_FAILED
Lock not engagedNothing to releaseIdempotent; nothing written200 { "released": false }
A restored dump carried in a non-terminal rowpg_restore --clean replaces backup_restore with the dump's own copy, which can hold a row that was mid-restore when the dump was taken — a pre-restore safety dump is taken while its own restore's row is still queuedThe restore closes every such row as soon as it learns pg_restore committed, before reconciling: status = 'failed', error_code = 'BACKUP_RESTORE_ABANDONED', finished_at = clock_timestamp(). If that write fails, the row blocks like any other and the lock names itNothing to do; if it is still open, force-clear blockingRestorePublicId
maintenance.json unreadableHand-edited or partially written fileMaintenanceGuard refuses traffic. The lock reads as engaged with reason: "unreadable", every other lock field null, and releasable from the restore rows and runner pid alone. Release with no blocker overwrites the file200, then 200 { "released": true }
Release rate limitMore than 10 release requests in a minute from one addressRefused429
The force-clear route cannot helpThe failed restore's row is already terminalForce-clear answers 409 BACKUP_RESTORE_NOT_FOUNDRelease the lock instead

While the lock holds, the health probes, the operator login and refresh, GET /api/auth/me, GET /api/auth/permissions, every route under /api/admin/system/restores/, and every permission-gated admin route stay reachable. Every other route answers 503 SYSTEM_MAINTENANCE_ACTIVE — see the API doc's 8.15.

5.7a Getting the uploaded files back

A backup taken with "Also archive uploaded files" produces two artifacts: database.dump + manifest.json + artifact.sig in one tar, and uploads.tar.gz beside them.

Download hands out the first three only. That is deliberate — they are exactly what the upload endpoint consumes, and folding a multi-gigabyte archive into that tar would break the round trip and make every routine dump download enormous.

What was missing is that nothing said so. The operator ticked the box, the panel reported the backup complete and showed the archive's size, and the download returned a file without it.

So there are two controls, and neither is labelled just "Download":

ControlGivesShown when
Download databasedatabase.dump, manifest.json, artifact.sigalways, with BackupDownload_READ
Download uploaded filesuploads.tar.gzthe run archived uploads, it completed, its artifacts are still on this host, and the operator holds the permission

That third condition is not padding. The retention sweep reclaims a backup to a remote copy by deleting the local files and setting local_artifact_present false — while leaving status as completed. Without checking it, the link renders on a swept run and answers 410; because the control is a plain link, the tab navigates away and shows raw JSON. A race can still reach that (the sweep can land between render and click), so the conditions make a refusal unlikely rather than impossible.

Verifying what you downloaded

The detail page shows Uploads archive sha256. Check the file you received:

sha256sum backup-<publicId>-uploads.tar.gz

The server deliberately does not hash the archive before sending it — see API 8.23. Hashing it first would read the whole file before the first header and turn every large download into a timeout, and nothing restores this archive, so there is no automated consumer to protect. The published digest is the control, and it verifies the bytes that actually arrived rather than the bytes that were on disk.

What the archive contains, and one thing to know about it

Everything under the upload root at dump time, as a gzipped tar. Two caveats:

  • The artifact HMAC covers the database dump and manifest only, never uploads.tar.gz. The signature badge says "Signed (database dump)" for exactly this reason.
  • The archive is taken from the local upload root regardless of the configured storage driver. On a deployment whose files live in object storage that directory may be empty or stale, and the run still records a size and a checksum. The rendered byte count next to the link is the operator's warning.

5.8 Custody of the artifact-signing key

The key that decides whether a backup archive can be verified — and therefore whether it can be restored — used to live inline in .env. It now lives in a file the application owns, with a lifecycle an operator can drive from the panel.

Rotation keeps the old key, and that is the whole design. A retired key is not a dead key: it is what still verifies every artifact signed before the rotation. Without a retired set, "rotate the signing key" would mean "make every existing backup unrestorable" — and an operator would discover that at the worst possible moment.

Reveal-once. generate and rotate return the key material in their response and nothing can retrieve it afterwards. The panel shows it in a dialog whose confirm button is disabled until the operator ticks "I have stored this key somewhere outside this server". If the value is lost, the recovery is to rotate again — not to read it back.

The migration never generates a fresh key. It copies the existing .env value byte for byte, because every artifact already on disk is signed with it. A fresh key would make them all unverifiable, and the symptom — BACKUP_UPLOAD_SIGNATURE_INVALID — reads like corruption rather than like a key change, which is the worst possible way for that to present.

Pruning a retired key, and the two refusals

Deleting a retired key is irreversible: the only way back is to import the same material again, if the operator still has it. Two independent checks refuse it, and force overrides both.

RefusalReadsWhy it is not the same check twice
Completed backups still carry this fingerprintbackup_run.signing_key_fingerprintThose artifacts would become unverifiable.
Some completed run is still unresolvedsignature_state IS NULL OR = 'signed_unknown_key'An unresolved run might be one this key alone can verify, and the count above cannot see it. Run "Resolve backup signatures" first.

Neither count can see an archive kept off this host — it has no row here. So a 0 never means "nothing depends on this key", and the panel says so in the same sentence as the number. That is also exactly the population import exists for.

Resolving signatures

resolve-signatures walks completed runs, reads each artifact's artifact.sig, and records which held key verifies it. It is batched: call again while remaining is above zero.

It re-examines rows already resolved as signed_unknown_key, because importing a key changes that answer. signed_unknown_key is a cache, not a fact — which is why every predicate that selects work reads signature_state IS NULL OR signature_state = 'signed_unknown_key', never IS NULL alone. A predicate that checked only NULL would resolve a row once, cache "no key held", and never look again, so importing the very key that verifies it would change nothing.

5.9 Who did this, from where

The admin panel is a BFF: the browser calls the Next server, and that server calls the API. So the API's req.ip was the panel process, and every admin action in the activity log read 127.0.0.1 — literally accurate, and useless for a log whose entire purpose is telling operators apart.

Three decisions worth stating, because each has an obvious wrong answer:

  • Read from the right, never split(",")[0]. Under nginx's standard $proxy_add_x_forwarded_for the leftmost entry is whatever the client sent — fully attacker-controlled. Counting in from the right, past the hops actually operated, is the only reading a caller cannot forge.
  • TRUSTED_PROXY_HOPS=0 means "derive nothing", and it is the default. With one trusted hop and a single-entry inbound header — every local run, and any deployment where nothing in front rewrites x-forwarded-for — the entry counted from the right is the browser's own value. Forwarding that under a valid token would hand the caller the address the audit log records, inverting the whole exercise. A deployment must say it has an address-rewriting proxy.
  • The address is not a rate-limit input. It reaches the activity log and nothing else. Feeding a client-influenced address into IpThrottlerGuard would let an unauthenticated attacker choose their own bucket on the admin credential path.

Until a deployment configures both halves, the log records the loopback address — which is merely useless, rather than wrong. That is the deliberate failure mode: an audit entry that says nothing beats one that says something false.

6. Admin Flows

6.1 Create

See 5.1 — POST admin/system/backups. Route: POST admin/system/backups. Permission: Backup_CREATE. Mutation side effects: backup_run insert, outbox enqueue. Cache invalidation: none (this module has no cached list reads). Audit: backup.create. Failure branches: 503 BACKUP_DISABLED, 409 BACKUP_ALREADY_RUNNING, 503 BACKUP_INSUFFICIENT_DISK_SPACE.

6.2 List / Read Detail

GET admin/system/backups and GET admin/system/backups/{publicId}. Permission: Backup_READ. No side effects. Failure: 404 BACKUP_NOT_FOUND on detail.

6.3 Update (pin/unpin)

PATCH admin/system/backups/{publicId} with {pinned: boolean}. Permission: Backup_UPDATE. Mutation: backup_run.pinned. Audit: backup.pin/backup.unpin with a changes[] diff. Failure: 404 BACKUP_NOT_FOUND.

6.4 Soft delete (prune)

DELETE admin/system/backups/{publicId} — see 5.3. Not a true soft delete in the usual sense: the row is never restorable back to completed, but it survives as a pruned audit record rather than being hard-deleted. Failure: 404, or 409 on any of the 3 floors.

6.5 Restore

See 5.4 in full. Route: POST admin/system/backups/{publicId}/restore. Permission: Backup_RESTORE, plus BACKUP_RESTORE_ENABLED. Takes an inline pre-restore safety dump, genuinely replaces the database via restore-runner.ts, and verifies row counts against the manifest afterward; ends at completed or failed (BACKUP_RESTORE_FAILED or, specifically for a row-count mismatch, BACKUP_RESTORE_VERIFICATION_FAILED), or at a manually force-cleared failed if the safety dump or the runner never completes.

6.6 Force-clear

POST admin/system/restores/{publicId}/force-clear — see 5.4's edge cases. Permission: Backup_RESTORE. Mutation: backup_restore.status='failed'; lifts the traffic lock unless the lock owes a post-restore reconciliation (see 5.4). Refuses while the state file names this restore's runner as a live process, and refuses a row that is already terminal — a restore that already failed is recovered by releasing the lock (6.8).

6.7 Settings (configure)

GET/PUT admin/system/backups/settings, backing a dedicated admin panel settings page — see Section 12.3 for the field-by-field editable/environment-only split. Permission: Backup_READ (get) / BackupConfigure_UPDATE (put) — a third, separate permission module from Backup_*, because authorising dump/pin/prune/restore is not the same act as authorising a change to the off-host replication destination. PUT is a full-replace write keyed by an optimistic version — a stale read is 409 BACKUP_SETTINGS_CONFLICT, not a silent overwrite.

6.8 Restore traffic lock (supporting flow, not its own feature)

GET admin/system/restores/lock (Backup_READ) and POST admin/system/restores/lock/release (Backup_RESTORE). The lock is engaged automatically when a restore is accepted and lifted automatically when it completes cleanly and has reconciled the restored database, or by force-clear when no reconciliation is owed; an operator releases it after a failed restore once no restore can be running and its reconciliation is not pending or reconcile_failed. No route engages it by hand. See 5.7 and the state diagram in Section 7.2.

6.9 Upload (catalogue)

See 5.6 in full. Route: POST admin/system/backups/upload. Permission: BackupUpload_CREATE — a fourth, separate permission module from Backup_*/BackupDownload_*/BackupConfigure_*, because introducing a database from outside is not the same act as running a dump of this one. Synchronous — 201 means the row is already catalogued, not queued.

6.10 Key custody

See 5.8 in full. Routes: GET admin/system/backup-key plus generate / import / rotate / resolve-signatures and DELETE .../retired/{fingerprint}. Permission: BackupKey_* — a fifth, separate permission module, because replacing the credential every artifact is authenticated against is not the same act as changing how often a dump runs. Audit: backup.key.generate / .import / .rotate / .prune. Every mutating route additionally re-checks the operator's password.

Failure branches: 400 BACKUP_KEY_REAUTH_REQUIRED, 401 BACKUP_KEY_REAUTH_FAILED, 409 BACKUP_KEY_NO_PASSWORD_SET, 409 BACKUP_KEY_ALREADY_EXISTS, 409 BACKUP_KEY_FINGERPRINT_UNKNOWN, 409 BACKUP_KEY_STILL_IN_USE, 422 BACKUP_KEY_PATH_NOT_CONFIGURED, 422 BACKUP_KEY_INVALID_MATERIAL, 422 BACKUP_KEY_STORE_UNREADABLE.

7. Lifecycle and State Transitions

7.1 backup_run.status

EntityFromEvent/ActionToGuard ConditionSide Effects
backup_run—POST .../backups or nightly cronqueuedEnabled, no run in progress, disk space sufficientOutbox event enqueued
backup_runqueuedWorker claims the jobrunningstatus IN ('queued','running') — retries can reclaimstarted_at set
backup_runrunningpg_dump (and optional tar) succeed, manifest writtencompleted—Bytes/sha256/manifest recorded; replication enqueued if configured
backup_runrunningpg_dump/tar fail or time outfailed—Sanitised error excerpt recorded
backup_runrunningNo heartbeat within dumpTimeoutMinutes + 5failedBackupSweepSchedulerBACKUP_DUMP_TIMED_OUT
backup_runcompletedRetention sweep or manual delete, floors passprunedNot pinned, not newest-overall, not newest-in-bucket, not most-recent-safety-dumpArtifacts deleted, artifact_dir=null, row survives

7.1a backup_run.signature_state

FromEvent/ActionToGuard ConditionSide Effects
(NULL)Run completes with a current key heldsigned—signing_key_fingerprint set in the same statement
(NULL)Run completes with no key heldunsigned!isStoreUnreadable()Permanent for that artifact
(NULL)Run completes while the key store is unreadable(stays NULL)—Deliberate: the answer is not known yet, and unsigned would be a permanent lie
(NULL)resolve-signatures finds a matching held keysignedartifact readable on this hostFingerprint recorded
(NULL)resolve-signatures finds no artifact.sigunsignedartifact readable on this host—
(NULL)resolve-signatures finds a signature matching nothing heldsigned_unknown_keyartifact readable on this hostFingerprint stays NULL
signed_unknown_keyimport adds the matching key, then resolve-signaturessigned—This transition is why the state is a cache, not a fact
signedanythingsigned—Never overwritten; a resolved row is final

The only transition into signed_unknown_key is "no held key matched at that moment", and the only way out is importing a key. A resolution predicate that read signature_state IS NULL alone would resolve each row once, cache "no key held", and never revisit it — so importing the very key that verifies an archive would change nothing, and the prune refusal built on those counts would be built on a stale answer. Every predicate therefore reads signature_state IS NULL OR signature_state = 'signed_unknown_key'.

The three CHECK constraints that hold the shape (signed implies a fingerprint, unsigned implies none, any state implies status = 'completed') are written as implications with IS DISTINCT FROM, never as a closed disjunction over the enum — a CHECK passes on NULL, and a future enum value must not silently fall into the wrong branch.

7.2 Restore traffic lock state (supporting)

FromEvent/ActionToGuard ConditionSide Effects
disengagedRestore accepted (POST .../restore)engagedPreflight passed, row insertedmaintenance.json, Redis mirror; restorePublicId, reason, engagedBy and reconciliation: "pending" recorded
engagedRestore completes cleanly (BackupRestoreProcessor.finish)disengagedIntegration reconciliation succeeded; the lock still names this restoreResurrected rows closed
engagedRestore fails after pg_restore may have committedengaged—Resurrected rows closed; reconciliation becomes reconciled or reconcile_failed
engagedRestore fails before pg_restore committedengaged—reconciliation removed
engagedReconciliation failsengaged—reconciliation: "reconcile_failed"; an API restart retries it
engagedPOST .../restores/{publicId}/force-cleardisengagedRow non-terminal; runner for this restore not alive; no reconciliation owedRow marked failed
engagedPOST .../restores/lock/releasedisengagedNo non-terminal row; no live runner pid; reconciliation not pending/reconcile_failed; lock unchanged since the request read itbackup.restore.lock_released audited; [backup] restore lock released logged

7.3 backup_restore.status and the live-state file's stage — two different state machines

The table column only ever holds queued or a terminal value, by design — pg_restore --clean replaces the table holding its own row mid-restore, so intermediate stages cannot live there:

The state file's stage field carries the real intermediate lifecycle (accepted → queued → draining → safety_dump → restoring → verifying → reconciling → completed/failed). safety_dump is written by BackupRestoreProcessor.takeSafetyDump; draining, restoring, verifying, completed and failed are written by restore-runner.ts as it fences, runs pg_restore, and checks row counts against the manifest. Only reconciling remains declared but never reached by any current code path. See 5.4.

9. Data and Side Effects by Flow

FlowDB WritesCache EffectsJobsRealtimeAnalyticsNotifications
Manual/scheduled backupbackup_run insert then update; outbox_eventsNonebackup.run (→ maybe backup.replicate)NoneNoneNone
Retention pruningbackup_run update (pruned); outbox_events (manual path)Nonebackup.pruneNoneNoneNone
DownloadNone (read-only)NoneNoneNoneNoneMongoDB AuditLog write (not fire-and-forget)
Restore requestbackup_restore insert then update (terminal status); outbox_events; restore-state.json; maintenance.json; plus an inline backup_run insert+update for the pre-restore safety dump (kind:'pre_restore_safety')Maintenance Redis mirror invalidated/rewrittenbackup.restore (consumed by BackupRestoreProcessor, which takes the safety dump then spawns restore-runner.ts)NoneNoneNone
Force-clearbackup_restore update (failed); maintenance.jsonMaintenance Redis mirror rewrittenNoneNoneNoneNone
Replicationbackup_run update (replication columns)Nonebackup.replicateNoneNoneNone
Upload and cataloguebackup_run insert (kind:'uploaded'); filesystem (dump, fresh manifest + signature)NoneNone (synchronous)NoneNoneNone
Traffic lock readNone (read-only)NoneNoneNoneNoneNone
Traffic lock releaseNone in PostgreSQL; maintenance.jsonRedis mirror rewritten, in-process 1s cache clearedNoneNoneNoneMongo audit document from ActivityAuditInterceptor

10. Error and Recovery Flows

10a. What the operator sees when a download is refused

Both download controls are plain <a href>. They have to be: the response is a stream, and the access token is an httpOnly cookie the browser cannot read, so the BFF route beside the page is what carries it. The consequence was that any non-2xx navigated the operator out of the dashboard and rendered the API's error envelope as raw JSON.

The BFF now answers a failed navigation with a 302 back to the page the operator started from, carrying ?downloadError=<code>. An inline notice renders the mapped message and the parameter is removed from the URL, so a refresh or a shared link cannot replay someone else's error.

Where the operator clickedWhere a refusal returns them
the list (?from=list on the link)the list
a backup's detail pagethat detail page

Three things about this are deliberate:

  • A non-navigation still gets JSON. Only a request carrying Sec-Fetch-Mode: navigate is redirected, so the route keeps an API-shaped contract for a same-origin fetch().
  • A CSRF refusal is never redirected. A 302 into the dashboard would erase the signal that the guard fired, and the guard deliberately answers before the authentication check so a cross-site probe cannot tell a signed-in operator from a signed-out one.
  • The message is looked up, never rendered from the URL. downloadError is attacker-supplied by construction — anyone can craft the link and send it. It only ever selects from a fixed map, and an unrecognised value gets a generic message. Rendering it would let whoever wrote the URL choose what the operator reads.

The obvious alternative — fetch the response in a click handler and show the error — was rejected: it means buffering the success case in the tab, and these artifacts are multi-gigabyte by design. That trades a bad error surface for an out-of-memory crash on the path that works.

ScenarioTriggerUser/System ExperienceRecoverySource
Dump fails mid-runpg_dump/tar subprocess errorRow failed; admin sees a sanitised error messageTrigger another manual run; investigate the server log for the raw errorBackupRunHandler.markFailed
API crashes right after a dump finishesKill between manifest.json write and row completionA completed artifact exists with no catalog rowreconcileFromDisk (boot, or on demand) rebuilds it from the manifestBackupManifestService.reconcileFromDisk
API crashes mid-dumpKill before completionRow stuck runningBackupSweepScheduler marks it failed within ~35 minutes by defaultbackup-sweep.scheduler.ts
Replication transfer failsNetwork, auth, disk-full on the remoteBackup stays completed; replicationStatus='failed'; visible via replicationFailureStreak in settingsInvestigate connectivity/credentials; the next run's replication attempt is independentBackupReplicationService.replicate
Replication stuck pendingWorker died mid-transferRow never resolvesBackupSweepScheduler ages it to failedsweepStalledReplications
Rate limit exceededToo many requests in the window429Wait for the window to reset (per-route budgets in Section 4)IpThrottlerGuard
Pre-restore safety dump fails, or does not reach completedDisk full, pg_dump error, or the safety-dump insert itself throwsRestore aborts before pg_restore ever runs; the traffic lock stays engaged; backup_restore marked failed (BACKUP_RESTORE_FAILED)Investigate the server log for the safety dump's own error, release the lock from the Backups page, then fix the underlying issue and resubmit the restoreBackupRestoreProcessor.takeSafetyDump
Restore runner never spawns, or dies mid-restoreProcess fails to start, or its heartbeat goes stale past BACKUP_RESTORE_ABANDON_SECONDSThe traffic lock stays engaged; backup_restore marked failed; a runner that went silent at or after restoring is reconciled as though it committedPOST .../force-clear if the row is not already marked; otherwise investigate the server log for the runner's own error and release the lock from the Backups pageSee 5.4
pg_restore succeeds, but restored row counts do not match the manifestCorruption, or a schema/data drift the earlier checks missedThe traffic lock stays engaged; backup_restore marked failed with BACKUP_RESTORE_VERIFICATION_FAILED specifically — distinct from the generic failure code, because the database is populated but wrongThe pre-restore safety dump is already in the catalog; an operator investigates the mismatch, then either accepts the state and releases the lock, or restores the safety dump as a new, ordinary restoreworkers/restore-runner.ts (verifyRowCounts)
A restore that ran needs to be undoneOperator restored the wrong backup, or a restore's row-count verification failedA pre_restore_safety dump was genuinely taken, is in the catalog, and is linked to this specific restore (backup_restore.safety_backup_run_id)Recovery is still manual, but directly addressable: read safetyBackupPublicId off RestoreLiveStateDto (populated from stage:"safety_dump" onward) and submit it as the source of a new, ordinary restore request — no need to browse the catalog by recencybackup-restore.ts, backup-retention.service.ts
force-clear used while pg_restore is genuinely still runningOperator force-clears an in-progress restore rather than waitingRefused with 409 BACKUP_RESTORE_ALREADY_ACTIVE once the runner has written its pid to the state file. Before that (pid 0) it is not detected: the row and the lock clear while the process starts unsupervisedAn operator confirms the process has stopped before treating the database as stablebackup-restore-admin.service.ts (forceClear)
Maintenance file corruptedHand-edited or partially written maintenance.jsonMaintenanceService.isActive() fails closed — refuses customer traffic rather than risk serving a half-restored database; the lock reads as engaged with reason: "unreadable"Release the lock, which overwrites the file when no restore can be running, or fix the file by hand; the read failure is logged loudlymaintenance.service.ts
Post-restore reconciliation failsThe integration reconciliation throws after the database was replacedThe lock stays engaged with reconciliation: "reconcile_failed"; release answers 409 BACKUP_RESTORE_RECONCILE_FAILEDRestart the API: resumeAfterRestart retries the reconciliation at boot and records reconciled on success; then releaseBackupRestoreProcessor.reconcileIntegration

11. Diagrams Required Per Module

  • Actor capability diagram: Section 3.
  • High-level module flow diagram: Section 5.1's sequence diagram, representative of the shared dump path.
  • Sequence diagram for each major flow: 5.1–5.7, plus 6.4's activity diagram.
  • State machine diagrams: Section 7, covering backup_run.status, the restore traffic lock, and the two-track restore state.
  • Data side-effect diagram: Section 9.
  • Error branch diagram: 6.4's prune flowchart, and the restore sequence diagram in 5.4.

12. Mandatory Feature and Flow Deep-Dive Pack

12.1 Feature Inventory With Minor Behaviors

FeatureMinor BehaviorActorTriggerUser/System ResultBackend Side EffectSource
Manual backupExplicit includeUploads:false is honoured even though the default is also falseSuperadminPOST .../backupsRun recorded with includesUploads:false deliberately, not by coincidence??, not `
Manual backupUnder a remote storage driver, a requested includeUploads:true is silently forced to falseSuperadminSameResponse later shows includesUploads:falseBackupArtifactService.archiveUploads checks StorageManager.hasRemoteDriver()backup-artifact.service.ts
Listsort query param accepted but not applied — only order (on createdAt) is honouredSuperadminGET .../backups?sort=...No error; sort silently ignored—backup-admin.service.ts
PinUnpinning a backup makes it immediately eligible for the next sweep if it's also past its bucket's retain countSuperadminPATCH .../backups/{id}Backup may be pruned on the next daily sweep with no further action—backup-retention.service.ts
DownloadFilename is derived from publicId alone, never the operator-editable noteSuperadminGET .../downloadPrevents CRLF header injection via a crafted note—backup-download-admin.controller.ts
DownloadDownload itself fails if its own audit record cannot be writtenSuperadminGET .../download500 BACKUP_DOWNLOAD_AUDIT_FAILED rather than a silent, unaudited downloadAuditLog.create is not fire-and-forget here, unlike everywhere else in the modulebackup-download-admin.service.ts
SettingsEditing dumpTimeoutMinutes takes effect on the next dump, with no restartSuperadminPUT .../settings—Read fresh from settings per dump, not cachedbackup-artifact.service.ts
SettingsChanging scheduleCron/scheduleTimezone reloads the cron registration liveSuperadminPUT .../settingsNext nightly run uses the new schedule without a deployBackupScheduleScheduler.reload()backup-admin.service.ts
SettingsstorageMode of both/remote_only is rejected unless a remote target is configured and remoteReplicationEnabled is also trueSuperadminPUT .../settings400 BACKUP_REMOTE_NOT_CONFIGUREDPrevents an operator believing backups are going off-site when nothing isbackup-settings.service.ts
RestoreBoth acknowledgement checkboxes must be literally true, checked server-side, not just in the formSuperadminPOST .../restore400 BACKUP_RESTORE_CONFIRMATION_MISMATCH on anything else—backup-restore-preflight.service.ts
RestoreLive-state polling never touches a database tableSuperadmin (polling UI)GET .../liveStays answerable even while every table is locked by pg_restore --cleanReads a file onlybackup-restore-admin.service.ts
RestoreRunner attaches to an already-running process instead of double-spawningWorker (BackupRestoreProcessor)Re-delivered backup.restore job (e.g. after an API restart)Prevents two concurrent pg_restore processes against the same databaseprocess.kill(pid, 0) liveness checkworkers/backup-restore.processor.ts
RestoreConnection fence is released before the state file reports completedRunnerEnd of a successful restoreThe supervisor's own completion query is never blocked by the fence it is about to observerestore-runner.tssame
UploadOperator's own manifest.json is never written to diskSuperadminPOST .../backups/uploadOnly two fields are read from it in memory; a fresh, server-generated manifest is always writtenPrevents reconcileFromDisk from laundering an editable "kind" claim on the next bootbackup-upload-admin.service.ts
UploadUploaded rows are pinned and excluded from GFS retention entirelySuperadminPOST .../backups/uploadAn operator who carried an archive in during an incident cannot have it swept away by the nightly sweepBACKUP_UPLOAD_MAX_RETAINED caps the alternative growth riskbackup-retention.service.ts
UploadContent scan renders schema SQL rather than trusting the TOCSuperadminPOST .../backups/uploadpg_restore --list cannot distinguish an ordinary trigger function from a SECURITY DEFINER one; the rendered SQL can409 BACKUP_UPLOAD_TOC_REJECTEDbackup-archive-content.util.ts
RestoreA pre-restore safety dump is taken inline and awaited, not enqueuedWorker (BackupRestoreProcessor)Every restore, before the runner spawnsRestore aborts (maintenance stays engaged) if the safety dump does not reach completedBackupRunHandler.run() called directly rather than through the outboxworkers/backup-restore.processor.ts
RestoreExisting database connections are drained, not killed outrightRunner (restore-runner.ts)After fencing new connections, before pg_restoreIn-flight customer requests (an order write, a payment record) get up to restoreDrainSeconds to finish on their own before being terminatedrestoreDrainSeconds — admin-editable, injected into the runner's own process environment since it has no DIworkers/restore-runner.ts
RestorerestoreTimeoutMinutes and restoreAbandonSeconds genuinely take effect on the next restore after being savedSuperadminPUT .../settings, then any subsequent POST .../restoreThe pg_restore timeout and the supervisor's abandon window both reflect the saved values, not the deploy-time environment defaultspawnRunner injects the timeout/drain values into the runner's env; supervise takes the abandon window as a parameter from the same settings snapshotworkers/backup-restore.processor.ts
RestorePost-restore row counts are checked against the source backup's own manifest, per tableRunner (restore-runner.ts)After pg_restore succeedsA mismatch is reported as BACKUP_RESTORE_VERIFICATION_FAILED, distinct from a generic failureverifyRowCounts(), exportedworkers/restore-runner.ts
RestoreA manifest table name is validated against a strict pattern before being interpolated into a queryRunner (restore-runner.ts)Row-count verificationAn unexpected identifier is reported as a mismatch, never queried/^[a-z_][a-z0-9_]*$/workers/restore-runner.ts
RestoreThe safety dump's id reaches both the response and the backup_restore rowSuperadmin (polling UI)GET .../live during/after safety_dumpsafetyBackupPublicId is directly readable from the response; backup_restore.safety_backup_run_id records the same link durablyBackupRestoreProcessor.takeSafetyDump writes bothbackup-restore.ts
Traffic lockA request is operator traffic only when its handler is not @Public() and declares a non-empty @Permissions(...)Any actorAny request while the lock holdsSigned-in routes with no permission decorator, /api/mobile among them, are refused like public onesMaintenanceGuard.isOperatorRequestmaintenance.guard.ts
Traffic lockSix literal exempt paths (health probes, login, refresh, /api/auth/me, /api/auth/permissions) and one prefix (/api/admin/system/restores/)Any actorAny request while the lock holdsThe operator can always get back in, watch the restore, clear a dead one, and release the lock, even after every session was invalidated by a restoreMAINTENANCE_EXEMPT_PATHS, MAINTENANCE_EXEMPT_PATH_PREFIXESmaintenance.guard.ts
Traffic lockRelease refuses while any restore row is non-terminal or any runner pid is alive, while the restore that engaged the lock owes a reconciliation, and when the lock changed after the request read it; it is idempotent when nothing is engagedSuperadminPOST .../restores/lock/release409 BACKUP_RESTORE_ALREADY_ACTIVE / BACKUP_RESTORE_RECONCILE_PENDING / BACKUP_RESTORE_RECONCILE_FAILED, or 200 { released: false }findLockReleaseBlocker, MaintenanceService.disengageIfbackup-restore-lock.service.ts
Traffic lockThe lock read reports releasable from the same checks release runsSuperadminGET .../restores/lockThe panel never offers a release that would be refused, except when a restore starts between the read and the clickfindLockReleaseBlocker, reconciliationbackup-restore-lock.service.ts

12.2 Business Process Diagram Pack

See Section 5 for a sequence diagram per flow and Section 6.4 for an activity diagram. A service blueprint for the manual-backup flow:

12.3 Business Rules and Policy Traceability

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactBackend ImpactTests
Uploads are excluded from a backup by defaultA database-only dump is dramatically cheaper — measured 21.9 MB vs 623 KB for one run on this database's uploads vs. its rows — and doubling steady-state disk on a single VPS for every deployment is a cost most will never needAn operator restoring a database-only backup gets rows that reference images that may no longer exist — broken images, no error, nothing that fails loudlyBACKUP_INCLUDE_UPLOADS env default, includeUploadsByDefault setting, ?? precedence in resolveIncludesUploadsCreateBackupDto.includeUploads is optional; absence means "use the default"BackupArtifactService.archiveUploads also force-disables under a non-local storage driver—
The catalog is an index of the filesystem, not the source of truthpg_restore --clean replaces every table including backup_run itself — a catalog living only inside the database it backs up is worthless in the exact disaster it exists forAn operator can trust the backup history to survive a restore or a database loss, because manifest.json is written to disk beside the artifacts, independent of the rowBackupManifestService.writeManifestFile (before completion), reconcileFromDisk (rebuilds missing rows)Not directly visible in any response, but is why reconcileFromDisk runs at bootmanifest.json written before the DB row is ever marked completebackup-manifest.service.spec.ts
storageMode governs where artifacts live; remote retention is out of scopeThe backup host is expected to run its own retention/rotation policy — this feature is a one-way push, never a remote pruneAn operator choosing remote_only must separately manage disk on the remote host; this module will never delete anything thereBackupReplicationService.replicate only ever pushesstorageMode in settings is local/both/remote_onlyLocal artifacts are reclaimed once verified-replicated, unless the run is the overall-newest—
Restore needs two independent gatesA permission can be granted by mistake; an environment variable cannot be granted from inside the running applicationAn operator cannot restore the database by permission alone — someone with shell access to the box must also have armed BACKUP_RESTORE_ENABLEDBackup_RESTORE (superadmin-only) + BACKUP_RESTORE_ENABLED (deploy-time)503 BACKUP_RESTORE_DISABLED if either is missingBackupRestorePreflightService.assertFeatureEnabled—
The application's DB role must not be a superuser to restorerolconnlimit fencing (the mechanism that keeps live traffic from contending with pg_restore --clean's locks) is not enforced by Postgres against a superuserAn operator who deployed with an overly-privileged DATABASE_URL is blocked from restoring until it is downgraded — a deliberate friction, not a bugassertApplicationRoleIsNotSuperuser409 BACKUP_RESTORE_APP_ROLE_IS_SUPERUSER——
--single-transaction is fixed, never configurableProbed directly: without it, a failed restore mid-way leaves the database in a worse state than before the attempt startedAn operator can never accidentally run a restore without this protectionHardcoded argv in restore-runner.tsN/A (not exposed anywhere)——
The traffic lock is engaged only by a restore, and released only once no restore can be running and the restored database is reconciledLifting the lock while pg_restore --clean holds every table puts users in front of a database mid-replacement; lifting it over an unreconciled database lets every integration key revoked after the dump authenticate againNobody can engage the lock by hand; only a superadmin holding Backup_RESTORE can release it, and only when no restore row is open, no runner is alive, and the restore that engaged it has recorded reconciled or owes nothingBackupRestoreLockService.releaseLock409 BACKUP_RESTORE_ALREADY_ACTIVE / BACKUP_RESTORE_RECONCILE_PENDING / BACKUP_RESTORE_RECONCILE_FAILEDMaintenanceService.disengageIfbackup-restore-admin-lock.integration.spec.ts, backup-restore-settle.integration.spec.ts
Three retention floors bind both the scheduled sweep and the manual delete identicallyAn earlier design enforced the floors in only one path, letting an operator delete every backup down to zero through the otherAn operator cannot delete the last restorable backup, the newest in a bucket, or the most recent restore's own safety dump, by any routeBackupRetentionService.assertPrunable, called from both sweep() and the admin remove()409 BACKUP_LAST_REMAINING_CANNOT_DELETE / BACKUP_PINNED_CANNOT_DELETE—backup-retention.service.spec.ts
A restore must not proceed without a completed pre-restore safety dumpProceeding without a way back trades a few minutes of downtime for an unrecoverable mistakeA restore that cannot safely be backed up first is refused rather than attemptedBackupRestoreProcessor.takeSafetyDump — inline, awaited, checked by row status before spawnRunner is ever calledSurfaces as the restore never advancing past safety_dump in GET .../live, then failedBACKUP_RESTORE_FAILED—
A restore is verified by row count before being trusted--single-transaction guarantees the database-level outcome is all-or-nothing, not that every table's data matches what was dumpedAn operator is told explicitly, via a distinct error code, when a restore applied but does not match its sourcerestore-runner.ts's verifyRowCounts() at the verifying stage409-shaped outcome surfaced through GET .../live's errorCodeBACKUP_RESTORE_VERIFICATION_FAILED—
An uploaded archive must be signed by this deployment's own keyA checksum alone authenticates nothing when the uploader supplies both the file and the hash in the same requestAn operator cannot catalogue an archive from an unrelated or untrusted source, even one that is a structurally valid pg_dumpBackupSignatureService.verify (HMAC-SHA256, timingSafeEqual)409 BACKUP_UPLOAD_SIGNATURE_INVALID, 503 BACKUP_UPLOAD_SIGNING_NOT_CONFIGURED—backup-signature.service.spec.ts
An uploaded archive's schema SQL is screened before it is trustedpg_restore --list's table of contents records an object's type, not its properties, and cannot distinguish a SECURITY DEFINER function from an ordinary oneAn operator cannot unknowingly catalogue an archive containing a privilege-escalation objectscanArchiveSql, run on pg_restore -f - --schema-only output409 BACKUP_UPLOAD_TOC_REJECTED—backup-archive-content.util.spec.ts

12.4 Tradeoffs and Product Rationale

Product DecisionUser BenefitEngineering BenefitAlternativeTradeoffRisk
Uploads opt-in, off by defaultFaster, cheaper backups by defaultRoughly halves steady-state disk on a single VPSAlways include uploadsRestoring a database-only backup produces broken image references silentlyMitigated only by the operator's own awareness — no warning is surfaced at restore time, since the row-count verification checks database tables against the manifest, not the presence of referenced upload files
Restore accept/execute split into request + async executionThe admin gets an immediate, honest response (202) rather than a held-open connection for a multi-minute operationMatches the reasoning that forced the execution logic out of the request process entirely — pg_restore --clean's table locks would otherwise deadlock the API against its own authFully synchronous restore in the HTTP handlerThe client must poll GET .../live rather than trust the accept responseIncludes an automatic pre-restore safety dump and post-restore row-count verification; the remaining gap is linking the safety dump back to the restore, not the execution or safety net itself
Verified read-back instead of trusting rsync's exit codeAn operator can trust a replicated status actually means the bytes are correct at the far endCompatible with a write-only deploy key (cannot run remote commands to self-verify)ssh ... sha256sum on the remoteCosts a full re-download during verification, doubling transfer time for that stepAccepted — correctness over speed for a database backup
Remote retention explicitly out of scopeSimpler mental model: this feature ships backups off-site, the receiving host manages its own spaceNo need to build and secure a remote-delete code path over the write-only transport this module deliberately usesHave this module also prune remotelyAn unmanaged remote host will fill up over time if the operator does not separately configure retention thereOperator responsibility, stated here rather than assumed
Uploaded archives authenticated by keyed HMAC, not a checksumAn operator can trust that a catalogued upload really came from a deployment holding the right key, not just any file with a matching hashReuses the same BackupSignatureService primitive rather than building a second authentication mechanismTrust an uploader-supplied checksumOperators must provision and protect the key file, and keep the key consistent across deployments that move backups between each otherFails closed (BACKUP_UPLOAD_SIGNING_NOT_CONFIGURED) rather than silently accepting unauthenticated archives
The signing key lives in an app-owned file, not in .env and not in the databaseIt can be generated, rotated and pruned by an operator without a redeploy, and a rotation does not orphan existing archivesNot the database — a dump would then contain the key that signs dumps. Not settings.json — that sits inside BACKUP_DIR, so the key would live in the directory it protectsLeave it inline in .env, where rotating it means editing a file on the box and restartingOne more file to back up separately, and it must never be inside BACKUP_DIR or the upload rootBoot refuses an unsafe path or mode; a corrupt file degrades to unreadable, which REFUSES verification rather than skipping it
Pre-restore safety dump taken inline and awaited, not enqueuedAn operator's mistake is always backed up before it becomes irreversibleThe ordering guarantee only holds if the dump finishes before the runner starts draining connections — enqueuing would let the two raceEnqueue the safety dump onto QueueName.BACKUP like any other dumpThe restore is blocked on the safety dump's own duration before pg_restore even startsIf the safety dump is ever slow enough to threaten the restore's own timeout budget
A failed restore leaves the traffic lock engaged until an operator releases itUsers never land on a database whose state nobody has checkedOne recovery route with a fixed refusal rule, instead of a general toggle that could lift the lock mid-restoreLift the lock automatically on any terminal outcomeThe API refuses non-operator traffic until an operator actsAn operator who does not open the Backups page leaves the lock engaged; the lock read is what surfaces it

12.5 Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorUser/System FeedbackSource
Manual backupDouble-click "Run now"Two requests in quick successionSecond is refused as soon as the first is queued — assertNoRunInProgress checks both queued and running, not only running409 BACKUP_ALREADY_RUNNING, or a DB constraint violation surfaced as a 500 in the rare race window where both checks land before either insert commitsbackup-run.ts
Scheduled backupTick fires while a manual backup is runningNightly cron coincides with an in-progress manual runSame assertNoRunInProgress check applies — the scheduled tick's createRun call is refused and logged, not silently skippedLogged error, no row created; next tick tries againbackup-schedule.scheduler.ts
Retention sweepEvery candidate in a bucket is protected by a floorSmall deployment with few backupsBucket simply does not shrink that dayNo error — the sweep logs 0 pruned for that bucketbackup-retention.service.ts
DownloadBackup's artifact was reclaimed to remote (remote_only) since it completedOperator clicks Download on an older remote_only runRefused — the file is not on this host410 BACKUP_ARTIFACT_MISSINGbackup-download-admin.service.ts
DownloadRecorded checksum no longer matches the file on diskDisk corruption, or an operator tampered with the fileRefused before streaming a single byte409 BACKUP_CHECKSUM_MISMATCHsame
RestoreConcurrent request while one is already acceptedTwo admins both submit a restoreSecond is refused409 BACKUP_RESTORE_ALREADY_ACTIVEbackup-restore-admin.service.ts
RestoreForce-clear called on a restore that is not actually stuckAny restore, including one genuinely mid-pg_restoreRefused once the state file names this restore's runner as a live process; no age threshold is checked, so a restore whose runner has not yet recorded a pid is cleared409 BACKUP_RESTORE_ALREADY_ACTIVE, or {cleared:true}same
RestoreAPI restarts mid-restoreRedeploy while pg_restore is runningThe detached runner survives (setsid, unref()); maxStalledCount: 0 stops BullMQ from re-running the handler and spawning a second pg_restoreThe runner finishes on its own. The job is not delivered again; at boot, BackupRestoreProcessor.resumeAfterRestart reads the lock and, when it names a pending restore whose runner has already spawned, supervises it again from restore-state.json and settles it (a reconcile_failed marker is reconciled again)workers/backup-restore.processor.ts
RestoreAPI restarts between the safety dump completing and the runner spawningRedeploy in a narrow windowOn reattach, the state file's stage is safety_dump or later, so takeSafetyDump is not called again — only the branch with no already-alive runnerPid calls itNo duplicate safety dumpworkers/backup-restore.processor.ts
RestoreSafety dump races a concurrent retention sweepA sweep tick runs while the safety dump is mid-flightNo conflict — assertPrunable only ever selects completed rows, so a queued/running safety dump cannot be pruned out from under the restore. The safety dump itself is not pinned (unlike an uploaded backup); once completed and linked, it is protected by the most-recent-safety-dump floor (now genuinely functional, since safety_backup_run_id is written) as well as by ordinary count-based retentionNo special handling neededbackup-retention.service.ts
ReplicationThe exact same job delivered twice (BullMQ at-least-once)Retry after a transient worker crashNo-op if already replicated—backup-replication.service.ts
Traffic lockRelease clicked while a restore is still runningA second operator, or a stale pageRefused; the lock stays engaged409 BACKUP_RESTORE_ALREADY_ACTIVEbackup-restore-admin.service.ts
UploadUploading the same archive twiceOperator submits identical bytes againSucceeds — no dedupe by content; a second kind:'uploaded' row is inserted, eventually capped by BACKUP_UPLOAD_MAX_RETAINED201 twice, then 409 BACKUP_UPLOAD_RETAINED_LIMIT_REACHEDbackup-upload-admin.service.ts
UploadArchive passes pg_restore --list but is actually truncatedFormat check only reads the table of contents at the frontThe sha256-vs-manifest comparison catches it, not the format check409 BACKUP_CHECKSUM_MISMATCHsame
isRestorable fieldA completed backup whose local artifact has since been reclaimed (remote_only, verified-replicated)Listing the backupBackupResponseDto.isRestorable reads only status==='completed' — it does not check localArtifactPresentThe admin UI may show a backup as restorable when a restore attempt would actually fail with 410 BACKUP_ARTIFACT_MISSING at preflightbackup-admin.service.ts (toResponseDto)
Traffic lockRestore engages the lock, then is force-clearedOperator abandons a stuck restoreThe lock is lifted as part of force-clear when the restore never reached pg_restore or the lock owes no reconciliation; otherwise it stays engaged with its markerTraffic resumes within the 1-second lock cache, or release once the restore settlesbackup-restore-admin.service.ts
Traffic lockRestore fails and its row is already terminalAny failure the processor recordedForce-clear refuses the terminal row; release lifts the lock409 BACKUP_RESTORE_NOT_FOUND from force-clear, 200 { released: true } from releasebackup-restore-admin.service.ts

12.6 Flow-to-Data Trace

FlowReadsWritesCacheJobs/EventsResponse Fields
Take a backupsettings, disk free spacebackup_run, outbox_events, manifest.json, artifact files—backup.run, maybe backup.replicatestatus, databaseDumpBytes, tableCount, totalRows, isRestorable
Retention pruningbackup_run per bucket, backup_restore (safety-dump floor)backup_run.status/prunedAt/artifactDir, filesystem delete—backup.prune(list view reflects pruned status)
Downloadbackup_run, filesystemMongoDB AuditLog——streamed bytes, not a JSON response
Restoreeverything preflight checks; source manifest.json (row-count verification)backup_restore (including safety_backup_run_id), restore-state.json, maintenance.json, outbox_events, inline backup_run (pre_restore_safety)maintenance Redis mirrorbackup.restore (consumed; takes and links safety dump, spawns restore-runner.ts)RestoreLiveStateDto.stage, safetyBackupPublicId
Replicationbackup_run, filesystembackup_run replication columns, remote host—backup.replicateremoteConfigured, replicationFailureStreak (via settings)
Uploadsettings.json (retained cap), uploaded manifest/signaturebackup_run, filesystem (dump, fresh manifest + signature)—— (synchronous)BackupResponseDto fields, same shape as any other backup
Traffic lock read / releaselock state (cache/Redis/file; release re-reads the file), backup_restore (non-terminal rows), restore-state.json (runnerPid)maintenance.json (release only)lock Redis mirror; Mongo audit backup.restore.lock_released (release only)—RestoreLockDto, RestoreLockReleaseDto.released

12.7 Experience Quality Checklist

  • The doc explains what the actor is trying to accomplish (each flow's Summary).
  • The doc explains what the backend does that the actor does not see (snapshot capture, manifest-before-row-completion, path recomputation for replication, connection fencing).
  • The doc covers minor flows and branches (12.1, 12.5).
  • The doc includes user (superadmin), worker/system, and the detached restore-runner actor.
  • The doc explains business logic, tradeoffs, and rationale (12.3, 12.4).
  • The doc maps every flow to API routes and backend side effects (Section 9, 12.6).
  • The doc includes diagrams appropriate to each flow type (Section 11).
  • The doc covers edge cases and failure recovery, including what the restore-safety net now genuinely does (pre-restore dump, row-count verification) and what still is not automated (linking the dump back to its restore), stated plainly rather than glossed over (Section 10, 12.5).

13. Completion Checklist

  • Every feature and minor action documented is listed: backup (manual + scheduled), retention pruning, download, upload/catalogue (SE-4), restore (request through genuine execution, safety dump, and row-count verification), replication, and the restore traffic lock (read and release) as a supporting flow.
  • Every actor has allowed and forbidden behavior (Section 3).
  • Every major and minor flow includes steps, branches, and diagrams (Section 5, 12.1, 12.5).
  • Every lifecycle has a transition table and state diagram (Section 7).
  • Every flow links to the API and backend docs.
  • Backend-doc dependencies are called out where they shape behavior — particularly the remaining safety-dump-linkage gap, cross-referenced to the backend doc's 16.8 Risk Register.
  • The upload endpoint (admin/system/backups/upload, SE-4) is fully documented in 5.6 and 6.9.

See Also

On this page

Backup Features and Flows1. Documentation Evidence2. Feature Summary3. Actor Matrix4. Capability Matrix5. User-Facing Flows5.1 Take a backup — manual ("Run now")SummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.2 Take a backup — scheduled (nightly)SummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.3 Retention pruningSummaryPreconditionsMain Flow — scheduled sweepMain Flow — manual deleteSequence DiagramBranches and Edge Cases5.4 Restore the live databaseSummaryPreconditionsMain FlowWhy --single-transaction is load-bearingSequence DiagramBranches and Edge CasesRe-keying the restored outbox by hand5.5 Off-host replicationSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.6 Upload and catalogue an operator-supplied archive (SE-4)SummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.7 Release a traffic lock a failed restore left engagedSummaryPreconditionsWhere reconciliation comes fromMain FlowSequence DiagramBranches and Edge Cases5.7a Getting the uploaded files backVerifying what you downloadedWhat the archive contains, and one thing to know about it5.8 Custody of the artifact-signing keyPruning a retired key, and the two refusalsResolving signatures5.9 Who did this, from where6. Admin Flows6.1 Create6.2 List / Read Detail6.3 Update (pin/unpin)6.4 Soft delete (prune)6.5 Restore6.6 Force-clear6.7 Settings (configure)6.8 Restore traffic lock (supporting flow, not its own feature)6.9 Upload (catalogue)6.10 Key custody7. Lifecycle and State Transitions7.1 backup_run.status7.1a backup_run.signature_state7.2 Restore traffic lock state (supporting)7.3 backup_restore.status and the live-state file's stage — two different state machines9. Data and Side Effects by Flow10. Error and Recovery Flows10a. What the operator sees when a download is refused11. Diagrams Required Per Module12. Mandatory Feature and Flow Deep-Dive Pack12.1 Feature Inventory With Minor Behaviors12.2 Business Process Diagram Pack12.3 Business Rules and Policy Traceability12.4 Tradeoffs and Product Rationale12.5 Flow Edge-Case Matrix12.6 Flow-to-Data Trace12.7 Experience Quality Checklist13. Completion ChecklistSee Also