Browse docs All docs
Docs / Database backups & self-service restore reference
Guidedeploymill://guides/backups

Database backups & self-service restore

Your app's managed database is backed up automatically, and you can now snapshot it on demand and restore it yourself, without filing a support ticket. This guide covers the backup tools (list_backups, create_backup, restore_backup, download_backup, and verify_backup) and exactly what's restorable today.

What's backed up, automatically

Every app with an internal-postgres managed database is backed up on a per-org schedule (default every 24h) with no setup and no per-app toggle. Each backup is a pg_dump compressed and AES-256-GCM-encrypted before it's stored, so the dump is never sitting in object storage as plaintext. Old backups are pruned on a rolling retention window (default 30 days).

You don't have to do anything for this to happen. The tools below are for when you want to act on those backups.

What's restorable

Database backendBacked upSelf-service restore
internal-postgres (the house default)✅ automatic + create_backuprestore_backup
Neon / Supabasethe provider manages durabilityrestore from the provider's own console
SQLite (file on a volume)✅ via nightly volume backupsoperator runbook (no self-serve tool yet)

restore_backup and create_backup cover the internal-postgres database only. For Neon/Supabase, use that provider's backups. Tenant volumes (and the SQLite databases that live on them) are backed up automatically too, but restoring a volume is still an operator-assisted runbook for now.

See your restore points: list_backups

list_backups({ applicationId })

Returns the app's backups, most recent first:

{
  "applicationId": "app_123",
  "databaseManaged": true,
  "provider": "postgres",
  "restorable": true,
  "backups": [
    { "backupId": "…", "databaseName": "cce153_app", "status": "success",
      "sizeBytes": 48213, "startedAt": "…", "completedAt": "…" }
  ],
  "count": 1
}

restorable: true means these can be replayed with restore_backup. Each successful backupId is a valid restore target.

Snapshot before a risky change: create_backup

About to run a destructive migration? Take a fresh restore point first:

create_backup({ applicationId })

This runs an on-demand backup right now (the same encrypted pg_dump the schedule uses), independent of the cadence, and returns the new backupId:

{ "ok": true, "backupId": "…", "databaseName": "cce153_app", "sizeBytes": 48213, "takenAt": "…" }

If the app has no managed database you'll get { ok: false, errorCode: "no_managed_database" }. For a Neon/Supabase database you'll get backup_provider_unsupported (use that provider's snapshots instead).

Recover the data: restore_backup

restore_backup replays a backup into the app's live database. It is destructive. It overwrites the current contents with the backup's. It's guarded the same way delete_app is, so you can't trigger it by accident.

Step 1: dry-run to see the plan (changes nothing):

restore_backup({ applicationId, backupId, dryRun: true })
{
  "ok": true, "dryRun": true, "restored": false,
  "plan": { "backupId": "…", "databaseName": "cce153_app",
            "takenAt": "…", "sizeBytes": 48213, "overwritesLiveData": true },
  "recommendation": "… Stop the app first (stop_app) … then call again with confirm …"
}

Step 2: stop the app (recommended) so live writes don't race the restore:

stop_app({ applicationId })

Step 3: apply, confirming what you're overwriting:

restore_backup({ applicationId, backupId, confirm: "<applicationId>" })

confirm must equal the exact applicationId, echoing back what you're about to overwrite. Omit it (or get it wrong) and the tool changes nothing and returns { ok: false, errorCode: "confirm_required", expectedConfirm }. On success:

{ "ok": true, "restored": true, "applicationId": "app_123",
  "databaseName": "cce153_app", "backupId": "…", "takenAt": "…" }

Step 4: start the app again (start_app) and verify.

Restore into a new database instead of overwriting ({ mode: "new-database" })

The default restore is in-place. It overwrites the app's live database. If you'd rather restore with zero blast radius, pass target: { mode: "new-database" } (PAID): the backup is restored into a freshly provisioned managed database and the live one is left untouched. No confirm is needed (nothing live is overwritten), and the call returns the new databaseId:

restore_backup({ applicationId, backupId, target: { mode: "new-database" } })
→ { ok: true, mode: "new-database", databaseId: "…" }

Then point the app at the restored copy with swap_database. It re-binds the app to the new databaseId and rolls it, keeping the old database as a rollback point:

swap_database({ applicationId, databaseId, confirm: "<applicationId>" })

This "restore as a new database, then swap it in" flow is the reversible alternative to an in-place overwrite (internal-postgres only).

Recoverable errors (success channel)

restore_backup returns these as { ok: false, errorCode } so you can branch and fix, rather than throwing:

errorCodeMeaning
no_managed_databaseThe app has no DeployMill-managed database.
restore_provider_unsupportedThe DB is Neon/Supabase, so restore from its console.
backup_not_foundNo backup with that backupId for this app. Check list_backups.
backup_not_restorableThat backup isn't a completed (success) one. Pick another.
confirm_requiredThe confirm echo was missing/wrong on a real (non-dry) call.
restore_failed (with partiallyApplied: true)The replay broke mid-way, so the DB may be partial. Re-run with the same backupId and it resets and replays from scratch.

Prove a backup actually restores: verify_backup

A dump file existing isn't the same as it being restorable. verify_backup (free on every plan) proves it: it spins up an ephemeral verification preview of the app whose isolated database is seeded from that backup, runs the deploy health gate, and stamps the result on the backup record:

verify_backup({ applicationId, backupId })
  • Pass: the seeded preview booted healthy. The backup is stamped

    verified_status: "good" with a verified_at time (the "last verified-good" badge get_app and the dashboard surface).

  • Fail: the seeded preview's deploy errored, stamped `verified_status:

    "failed"`.

Either way you get a live preview URL to click, and the preview carries a short TTL so it self-reaps. It's a drill, not a lasting environment. Internal-postgres only. A Neon/Supabase or no-managed-DB app returns a coded { ok: false, errorCode }.

Take a copy off-platform: download_backup

download_backup (owner/admin only, audited) hands back a short-lived presigned URL to a decrypted copy of a backup so you can pull it off-platform:

download_backup({ applicationId, backupId })                 → a database .sql.gz
download_backup({ applicationId, backupId, kind: "volume" }) → a volume .tar.gz (from list_backups → volumeBackups)

The encrypted blob is decrypted server-side (the long-term storage key never reaches the agent or UI, the secret hand-off posture) into a short-lived temp object, and you get a presigned GET URL that dies within ~5 minutes. The decrypted .sql.gz (or volume .tar.gz) is full PII, so the download is gated to owner/admin and recorded in the audit trail.

Security notes

  • Backups are AES-256-GCM-encrypted at rest with the same keyring as the secrets

    vault. The storage location and credentials are server-side and never returned by any tool.

  • restore_backup and create_backup are recorded in the audit trail, so

    every snapshot and recovery is attributable.