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 with no setup and no per-app toggle: every 24h on Explore and Builder, every 6h on Studio and Enterprise. (Free Explore has no volumes to back up, but its managed database is on the same 24h cadence as Builder.) 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). Read the resolved cadence for an app from get_app's backupPolicy.frequencyHours rather than assuming it.
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
| What | Backed up | Self-service restore |
|---|---|---|
| The managed Postgres database DeployMill provisions for your app | ✅ automatic + create_backup | ✅ restore_backup |
| Tenant volumes (and any file datastore on them, e.g. an app-managed SQLite file) | ❌ not by default — the volume-backup path ships off (see below); where an operator has enabled it: scheduled + create_backup({ kind: "volume" }) | ❌ no tool; operator-assisted, and only if the path was enabled |
| Object-storage buckets (R2) | ❌ not snapshotted by DeployMill | — |
| A legacy app whose database is hosted by an external vendor | that vendor manages durability | restore from that vendor's own console |
Because DeployMill runs the database itself, the backups above are ours to take and ours to prove — which is what makes a verified restore possible rather than a vendor's assurance. restore_backup and create_backup cover the managed database only.
Volumes are not backed up on a default deployment. deploymill has a file-level volume-backup path (portable, encrypted copies shipped to S3-compatible object storage with rolling retention), but it is gated behind an operator flag (
VOLUME_BACKUP_RESTIC_ENABLED) that defaults to off, and the older storage-class snapshot job it replaced has been retired. Check the real posture at runtime:get_appreports a per-mountbackupblock whoseenabledisfalsewhen nothing is backing that volume up, andlist_backupsreturns an emptyvolumeBackupsarray. If you need durability, keep the data in the managed database — that one is backed up. Full detail indeploymill://guides/storage.
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, "checksum": "…", "verified": "good", "verifiedAt": "…",
"startedAt": "…", "completedAt": "…" }
],
"volumeBackups": [],
"count": 1,
"volumeCount": 0,
"total": 1,
"volumeTotal": 0,
"limit": 50,
"offset": 0,
"hasMore": false
}
Each list is a window. count/volumeCount are what came back in this page; total/volumeTotal are how many records exist. limit (default 50, max 200) applies to each list, offset pages into older records, and hasMore / nextOffset say when and where to page. Pass kind: "database" or kind: "volume" to fetch just one of the two lists.
restorable: true means these can be replayed with restore_backup. Each successful backupId is a valid restore target. checksum is a sha256 of the stored artifact (null on rows written before checksums shipped). It stamps what is stored, so it won't match a file you downloaded — download_backup returns the digest of the copy it hands you.
verified and verifiedAt are the outcome of the last verify_backup drill on that specific backup — "good", "failed", or null when it has never been drilled. This is what you poll after starting a drill (see below).
volumeBackups is a separate array of file-level volume backups ({backupId, mountId, name, status, sizeBytes, checksum, startedAt, completedAt}) — volume rows carry the same sha256 checksum field, holding the content id of the stored file-level backup (null on rows taken before it shipped); it is the source of the backupId you pass to download_backup({ kind: "volume" }). It is empty unless the operator has enabled the volume-backup path — see What's restorable. count counts the database backups only, not the volume ones.
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" }. A legacy app whose database is hosted by an external vendor gets backup_provider_unsupported (use that vendor's snapshots instead).
Snapshot a volume instead: create_backup({ kind: "volume" })
kind defaults to "database". Pass "volume" to snapshot the app's mounted volume(s) on demand instead of waiting for the scheduled sweep — the same file-level export, landing in the same place, so it shows up in list_backups under volumeBackups:
create_backup({ applicationId, kind: "volume" })
Which mounts. With no selector, every volume mount on the app is backed up — that's the usual intent ("give me a restore point before I do this"). Pass mountId (from get_app or list_backups → volumeBackups) or volumeName (the name in .deploymill/project.json) to snapshot exactly one.
It doesn't finish inside the call. A volume export copies the volume first, which takes tens of seconds on a near-empty volume and longer as it fills, so by default the call returns as soon as the backup record exists and the export keeps running:
{
"ok": true, "kind": "volume", "applicationId": "app_123",
"completed": false, "waitTimedOut": false, "failed": 0,
"volumes": [
{ "backupId": "…", "mountId": "m_1", "name": "data", "status": "running", "sizeBytes": null }
],
"pollWith": "Poll list_backups({applicationId}) …"
}
To find out how it went, call list_backups({ applicationId }) and look up that backupId in volumeBackups: status: "running" means still exporting, "success" means the restore point exists, "error" means it failed and you can call create_backup again.
Prefer to block? Pass wait: true (optionally with timeoutMs, 10s–15min, default 120s). You then get terminal per-volume status values and completed: true — or, if the export outlasts the timeout, the same poll-me response plus waitTimedOut: true, which means it is still running, not that it failed.
Coded preconditions on the success channel:
errorCode | What to do |
|---|---|
volume_backup_unavailable | The volume-backup path isn't enabled on this server. Keep durable state in the managed database, which is backed up. |
no_volume_mounts | The app has no volume mount. Declare one under mounts in .deploymill/project.json and run reconcile_project, or use kind: "database". |
mount_not_found | Your mountId/volumeName matched nothing — the response's availableMounts lists every addressable mount. Or omit the selector to back up all of them. |
volume_backup_already_running | A backup of one of those mounts is already in flight, and a second was deliberately not started. busyMounts names them with their in-flight backupId; poll until it settles and retry, or pass a mountId that isn't busy. |
Volume backups are crash-consistent point-in-time copies, not application-consistent — for a database, back up the database.
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:
errorCode | Meaning |
|---|---|
no_managed_database | The app has no DeployMill-managed database. |
restore_provider_unsupported | The DB is hosted by an external vendor (a legacy app), so restore from that vendor's console. |
backup_not_found | No backup with that backupId for this app. Check list_backups. |
backup_not_restorable | That backup isn't a completed (success) one. Pick another. |
confirm_required | The 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. |
provision_failed | Only on target: { mode: "new-database" } — provisioning the fresh database failed, so nothing was restored. Retry, or fall back to an in-place restore. |
upgrade_required (with upgradeGuide, and upgradeUrl when the operator has configured one) | Only on target: { mode: "new-database" } — that mode needs a paid plan. In-place restore stays free on every plan. Read the upgradeGuide resource before responding. |
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 }) → starts the drill, returns immediately
verify_backup({ applicationId, backupId, wait: true }) → blocks for the verdict inline
- Pass: the seeded preview booted healthy. The backup is stamped
verified_status: "good"with averified_attime (the "last verified-good" badgeget_appand the dashboard surface). - Fail: the seeded preview's deploy errored, stamped `verified_status:
"failed"`.
The preview carries a short TTL so it self-reaps. It's a drill, not a lasting environment.
It starts and you poll — the drill outlives the call
The whole drill (provision a preview → build it → run the health gate → wait for its route) reliably takes 40–60 s or more, which is longer than most MCP clients will hold a single tool call open. So verify_backup defaults to wait: false: it checks every cheap precondition synchronously — ownership, that there's a managed database, the provider, that the backup exists and succeeded — and returns as soon as the drill is triggered:
{ "ok": true, "verifyPending": true, "ref": "verify-backup-<backupId>", "pollWith": "…" }
Read the verdict off list_backups, where every database backup carries verified ("good", "failed", or null) and verifiedAt. A null verdict has two meanings — a verdict is only ever stamped by a drill that actually ran — so disambiguate with list_previews({ parentApplicationId, ref }), passing the ref you got back so the listing returns just the drill preview: provisioning.status "pending" means still drilling; "ready" with verified still null means the drill finished and the stamp is landing right now (poll list_backups again in a moment); and "failed" (or a ref that never shows up) means the drill couldn't start — read its errorCode/stage, fix that, and re-run. Re-running for the same backup is idempotent; it reuses that preview rather than leaking another.
Pass wait: true when your client tolerates a multi-minute call and you want verified, previewUrl, and healthSummary inline.
On a wait: true pass, the drill also waits (bounded) for that URL to actually answer before returning it, because attaching a hostname and serving on it are seconds apart:
edgePending: false— the URL was answering when it was handed back.edgePending: true(plus anedgeNote) — the verdict is real, but the URLwasn't confirmed serving yet. Retry it shortly; a first failed request there means "too early", not "the drill failed".
Covers the managed Postgres database: a no-managed-DB app, or a legacy app on an externally-hosted database, 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.
The result carries a checksum — the sha256 of the exact file behind url, for both kinds — so you can verify the transfer after fetching it:
sha256sum backup.sql.gz # must equal the response's checksum
(That digest is of the decrypted/materialized copy you receive, which is a different artifact from the stored one list_backups stamps.)
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_backupandcreate_backupare recorded in the audit trail, soevery snapshot and recovery is attributable.