Snapshot before a risky migration, restore if it goes wrong
Take a labelled snapshot, run the migration, and restore into a new project if it fails. Why restore does not overwrite, and what that means for your rollback plan.
Before a migration you can’t easily undo, take a snapshot: on Infrai that’s POST /v1/db/snapshot/create with a project_id and a label. If the migration goes badly, POST /v1/db/snapshot/restore/{snapshot_id} brings the data back — as a new project rather than overwriting the old one, which is the single most important thing to understand before you write the rollback runbook.
Restore is a recovery, not a rewind. Plan the cutover accordingly.
Take the snapshot
curl -sS -X POST "https://api.infrai.cc/v1/db/snapshot/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"project_id": "dbp_7Uu2kQxWvR4mBn8d", "label": "pre-migration-0042-add-order-index"}'
{
"ok": true,
"data": {
"snapshot_id": "dbs_4kQ9mVzR1sXbNt",
"project_id": "dbp_7Uu2kQxWvR4mBn8d",
"db_id": "db_3kQ9mVzR1sXbNt",
"label": "pre-migration-0042-add-order-index",
"size_bytes": 184549376,
"created_at": "2026-09-21T02:59:04Z",
"state": "ready",
"vendor": "tencent_pg",
"expires_at": null
}
}
Put the migration’s identity in the label. “pre-migration” tells you nothing at three in the morning; pre-migration-0042-add-order-index tells you exactly which change this precedes, and GET /v1/db/snapshot/list?project_id=... then reads like a history rather than a pile.
state is worth checking rather than assuming — a snapshot that isn’t ready isn’t a rollback plan.
Restore into a new project
curl -sS -X POST "https://api.infrai.cc/v1/db/snapshot/restore/dbs_4kQ9mVzR1sXbNt" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"snapshot_id": "dbs_4kQ9mVzR1sXbNt", "new_name": "checkout-service-rollback-0042"}'
The response is a full project record — a new project_id, its own db_id, state, primary_branch and vendor. Your old project is untouched and still serving whatever it was serving.
That’s the design, and it’s the safer one: an in-place restore that fails halfway leaves you with neither the old data nor the new. But it does mean the rollback has a step your runbook has to name, which is repointing the application at the restored database.
The whole procedure
import os
import time
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def snapshot(project_id: str, label: str) -> dict:
resp = SESSION.post(f"{API}/v1/db/snapshot/create",
json={"project_id": project_id, "label": label}, timeout=60)
resp.raise_for_status()
return resp.json()["data"]
def wait_ready(snapshot_id: str, timeout_seconds: int = 600) -> dict:
"""A snapshot you haven't confirmed is 'ready' is not a rollback plan.
Poll rather than sleeping a guessed interval."""
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
resp = SESSION.get(f"{API}/v1/db/snapshot/get/{snapshot_id}", timeout=25)
resp.raise_for_status()
data = resp.json()["data"]
if data.get("state") == "ready":
return data
time.sleep(5)
raise TimeoutError(f"snapshot {snapshot_id} not ready within {timeout_seconds}s")
def restore(snapshot_id: str, new_name: str) -> dict:
resp = SESSION.post(f"{API}/v1/db/snapshot/restore/{snapshot_id}",
json={"snapshot_id": snapshot_id, "new_name": new_name}, timeout=120)
resp.raise_for_status()
return resp.json()["data"]
def guarded_migration(project_id: str, migration_id: str, run_migration) -> dict:
snap = wait_ready(snapshot(project_id, f"pre-{migration_id}")["snapshot_id"])
try:
run_migration()
return {"ok": True, "snapshot_id": snap["snapshot_id"]}
except Exception as exc:
recovered = restore(snap["snapshot_id"], f"{migration_id}-rollback")
return {"ok": False, "error": str(exc), "restored_project_id": recovered["project_id"]}
if __name__ == "__main__":
print(guarded_migration(os.environ["INFRAI_DB_PROJECT_ID"], "0042-add-order-index",
lambda: print("run alembic/flyway/sqitch here")))
The wait_ready step is the one people skip. Taking a snapshot and starting the migration in the same breath means the migration may be underway before the snapshot has captured a consistent state.
What a snapshot is not
| Expectation | Reality |
|---|---|
| A rewind of the live database | no — restore creates a new project |
| A continuous backup | no — it captures the moment you asked |
| Point-in-time recovery to any second | no — you get the snapshots you took |
| Free to keep | no — it occupies space and accrues rent |
| Automatic before migrations | no — you call it, or your pipeline does |
The absence of point-in-time recovery is the real limitation. If your requirement is “restore to 14:32:07 yesterday” rather than “restore to the snapshot we took before the deploy”, this isn’t the right tool and a managed Postgres with continuous archiving — Neon or a cloud provider’s own service — is what you want. Snapshots are discrete, deliberate and cheap; continuous PITR is neither of the first two.
Cost, and the cleanup nobody schedules
Creating a snapshot has a small one-time fee, live in the billing block of GET /v1/discovery/db.snapshot.create (verified 2026-09-21). The part that accumulates is space: each snapshot’s size_bytes sits on disk and accrues standing rent by occupied gigabyte, so the pre-migration snapshots from the last eighteen deploys are a line item.
Read what’s accrued from GET /v1/account/usage and keep a retention rule: delete a snapshot once its migration is known good. A sensible policy is “keep the last three, plus anything labelled for a release” — and since platform rates drift downward over time, read them live rather than working from numbers in a document.
Where the rest of the runbook lives
The pieces around a guarded migration are already on this key, which is the part that isn’t reproducible when your database, your scheduler and your alerting are three vendors. The nightly retention sweep runs on POST /v1/cron/create; the migration’s own log lines go to POST /v1/logs/ingest; a failure that triggered a restore lands in POST /v1/errors/capture; and the “we rolled back” notice goes out with POST /v1/email/send. One credential, one bill, one place to read the story afterwards.