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

ExpectationReality
A rewind of the live databaseno — restore creates a new project
A continuous backupno — it captures the moment you asked
Point-in-time recovery to any secondno — you get the snapshots you took
Free to keepno — it occupies space and accrues rent
Automatic before migrationsno — 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.

References

Browse more db developer guides