all posts

Spin Up an Ephemeral, Seeded Postgres per Pull Request

Ajay Kumar··9 min read

Every engineering org has the same database. It's called staging, or dev, or — if you're honest — prod-2, and everyone shares it. Its schema is three migrations behind main, its seed data is a fossil record of every half-finished feature since 2022, and there is exactly one person who knows which rows you're allowed to delete. When a PR needs a destructive migration, it doesn't get run against staging; it gets a Slack thread titled "heads up, don't touch the DB this afternoon." The shared staging database isn't a testing environment. It's a coordination problem with a connection string.

The clean fix is obvious and, until recently, impractical: give every pull request its own real Postgres. Not a mock, not an in-memory shim, not sqlite pretending to be Postgres — an actual Postgres 16, migrated to the PR's exact schema, seeded with production-shaped fixtures, reachable by a connection string, and deleted the moment the PR closes. The reason nobody did this is that standing up a fresh database per PR meant re-running your migrations and re-seeding your fixtures every single time, which for a mature app is minutes of setup you pay on every open, every push, every reopen. This post is about the trick that makes it cheap: snapshot a fully-migrated, fully-seeded database microVM once, then fork that golden snapshot per PR so each PR gets an identical warm database in sub-second — no migrations, no seeding, no waiting.

Why per-PR databases beat one shared staging DB

The case for isolation is the same case as everywhere else in this blog — a shared mutable thing is a shared blast radius — but for a database it bites in three specific, recurring ways that anyone who's shared a staging DB will recognize.

First, test isolation. When two PRs, or two CI runs, or a developer and a nightly job all point at the same database, they see each other's writes. A test that asserts "there are 3 users" passes locally and fails in CI because another run inserted a fourth. You spend an afternoon chasing a heisenbug that is really just two tenants of the same database stepping on each other. The standard workarounds — wrap every test in a transaction and roll back, or truncate everything between runs — work until they don't: they break the moment your code under test manages its own transactions, spawns a background worker, or the migration itself is what you're testing.

Second, destructive migrations. This is the one that keeps the shared DB frozen. A migration that drops a column, rewrites a table, or backfills ten million rows cannot be tried against a database other people are using — if it's wrong, or slow, or locks a table, it takes everyone down with it. So the risky migrations, the exact ones that most need a real dry run against realistic data, are the ones that never get one. They go straight to prod on faith. A per-PR database is a database you are allowed to destroy, because it exists only for this PR and is about to be deleted anyway.

Third, parallel CI. If your test suite talks to a real database and there's one shared database, your CI is serialized on it — two branches can't run their integration tests at once without corrupting each other's state. Give each run its own database and the whole suite parallelizes: ten PRs, ten databases, ten CI jobs running at the same time with zero coordination. The database stops being the bottleneck that makes your build queue back up.

The shared staging DB fails at exactly the moments it matters most: when a migration is risky, when tests run in parallel, and when you need a clean, known state. Those aren't edge cases — they're the daily work of shipping. Per-PR databases don't harden the shared database; they remove the sharing.

The thing that made this expensive: setup, every time

So why doesn't everyone already do per-PR databases? Because a database isn't an empty box — it's an empty box plus everything you have to do to make it useful. Spin up a fresh Postgres and it's just an idle process with an empty schema. Before a single test can run against it, you have to apply every migration you've ever written (a mature app can have hundreds), then load a seed dataset shaped like production so your tests exercise realistic data — foreign keys populated, enums filled, enough rows that a query plan looks like the real thing. On a real codebase that migrate-and-seed step is not seconds; it's minutes. Pay it once and it's fine. Pay it on every PR open, every push that triggers CI, every reopen, across dozens of concurrent branches, and you've built a system whose dominant cost is re-doing identical setup work over and over.

The insight that dissolves this: the migrated-and-seeded state is identical for every PR. Every one of those databases starts from the exact same place — schema at head, fixtures loaded — and only diverges once the PR's own code and tests start writing to it. You are re-computing a constant. The trick is to compute it once and clone the result.

The trick: fork from a golden snapshot

PandaStack runs each managed Postgres as its own Firecracker microVM. Because it's a microVM, it can be snapshotted — frozen with its memory and disk state captured — and then forked: cloned into a new, independent VM that starts from that exact captured state. That's the whole pattern. You do the slow work once, into a snapshot, and then every PR gets a copy of the finished result instead of redoing it.

  1. Build the golden database once. Create a Postgres microVM, run your full migration chain against it, load your production-shaped seed fixtures, and let it settle. This is the slow step — the same minutes you used to pay per PR — but you pay it a single time (in a nightly job, or whenever main's schema changes).
  2. Snapshot it. Freeze that fully-migrated, fully-seeded VM into a golden snapshot. The snapshot captures the running Postgres — schema, data, and warm in-memory state — as one restorable image.
  3. Fork it per PR. When a PR opens, fork the golden snapshot. Copy-on-write means the fork doesn't duplicate gigabytes — it shares the golden pages and only writes what the PR changes. Each PR gets its own private, writable, identical Postgres in sub-second.
  4. Use it, abuse it, throw it away. The PR's CI runs migrations on top (testing the PR's own migration against real seeded data), inserts test rows, drops tables — whatever it wants. It's the PR's database. When the PR closes, delete it. Nothing leaks back to the golden snapshot or to any other PR.

The copy-on-write fork is why this works economically. A PandaStack same-host fork lands in roughly 400–750ms (cross-host 1.2–3.5s); it reflinks the rootfs and restores memory rather than copying a fresh disk and re-booting Postgres. Compare that to the alternative: a plain managed database create takes 30–90 seconds because it blocks until Postgres has bootstrapped from scratch — and that's before you've run a single migration or loaded a single fixture. Forking a golden snapshot skips the bootstrap and the setup in one move. Your PR databases go from minutes to under a second, and they're warmer besides — Postgres is already running, its caches already primed from the golden state.

The mental model: the golden snapshot is your database's `node_modules` after a clean install. You don't reinstall dependencies for every branch — you clone the installed tree. Fork-from-snapshot does the same thing for a migrated, seeded Postgres: compute the expensive state once, then hand every PR a copy-on-write clone of it.

Building the golden snapshot and forking it

Here's the pattern with the Python SDK. First, the one-time job that builds and snapshots the golden database: create a Postgres-16 sandbox, run your migrations and seeds inside it, then snapshot it. Then, the per-PR path: fork that snapshot and hand back a ready-to-use connection. Set PANDASTACK_API_KEY in the environment first.

from pandastack import PandaStack

ps = PandaStack()  # reads PANDASTACK_API_KEY, base https://api.pandastack.ai

# --- ONE-TIME: build the golden snapshot (run in a nightly job or on
#     every schema change to main). This is the slow step you used to pay
#     per PR. Now you pay it once. ---
def build_golden() -> str:
    sb = ps.sandboxes.create(template="postgres-16")  # its own Postgres microVM

    # Apply the FULL migration chain against a clean Postgres, then load
    # production-shaped seed fixtures. Minutes of work — done exactly once.
    sb.exec("cd /app && ./migrate.sh up")
    sb.exec("psql -f /app/seeds/production_shaped.sql")

    # Freeze the migrated + seeded VM into a golden snapshot.
    snap = sb.snapshot()          # captures schema, data, warm PG state
    sb.delete()                   # the builder VM is disposable now
    return snap.id                # e.g. "snap_golden_2026_07_08"

# --- PER-PR: fork the golden snapshot. Copy-on-write, sub-second, and
#     already migrated + seeded. No bootstrap, no re-running setup. ---
def db_for_pr(golden_snapshot_id: str, pr_number: int) -> str:
    # A fork of the golden DB: same-host lands ~400-750ms. The fork shares
    # golden pages until this PR writes, so it's cheap and identical.
    db = ps.sandboxes.fork(
        parent_id=golden_snapshot_id,
        metadata={"pr": str(pr_number), "kind": "pr-database"},
    )

    # It's THIS PR's database now. Run the PR's own migration on top of real
    # seeded data, drop tables, insert junk — it's disposable.
    db.exec("cd /app && ./migrate.sh up")   # test the PR's migration for real

    # Native TLS connection string, reachable over SNI:
    #   postgres://pandastack:<pw>@<id>.db.pandastack.ai:5432/pandastack
    return db.connection_url

# On PR close, tear it down — nothing survives, nothing leaked to golden.
def teardown(db_id: str) -> None:
    ps.sandboxes.delete(db_id)

Two things worth calling out. The fork carries the golden state, so the PR database is already migrated and seeded the instant it exists — the `migrate.sh up` inside `db_for_pr` is only applying the PR's own new migration on top, which is the thing you actually want to test. And the connection is reachable over SNI at `<id>.db.pandastack.ai`: the sandbox's ID is the hostname, TLS routes the connection to the right database VM, and nothing about wiring up the connection changes between PRs. Point your test suite's DATABASE_URL at it and go.

Wiring it into CI: create on open, delete on close

The lifecycle maps cleanly onto pull-request events. Fork a database when the PR opens (and reuse or refresh it on each push), run the suite against it, and delete it when the PR closes or merges. Here's the shape of it as a GitHub Actions workflow — a fork job on PR open/sync and a cleanup job on close:

name: pr-database
on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

jobs:
  # Fork a fresh DB from the golden snapshot for this PR's tests.
  test:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    env:
      PANDASTACK_API_KEY: ${{ secrets.PANDASTACK_API_KEY }}
      GOLDEN_SNAPSHOT: ${{ vars.GOLDEN_DB_SNAPSHOT_ID }}
      PR: ${{ github.event.number }}
    steps:
      - uses: actions/checkout@v4
      # Fork the golden snapshot -> a warm, migrated, seeded Postgres in <1s.
      - name: Fork PR database
        run: |
          DB_URL=$(pandastack sandboxes fork \
            --parent "$GOLDEN_SNAPSHOT" \
            --metadata pr=$PR \
            --output connection_url)
          echo "DATABASE_URL=$DB_URL" >> "$GITHUB_ENV"
      # The PR's own migration runs on top of real seeded data.
      - run: ./migrate.sh up
      - run: npm test        # integration tests hit a private, real Postgres

  # On PR close/merge, destroy the database. Nothing survives.
  cleanup:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    env:
      PANDASTACK_API_KEY: ${{ secrets.PANDASTACK_API_KEY }}
      PR: ${{ github.event.number }}
    steps:
      - name: Delete PR database
        run: pandastack sandboxes delete --by-metadata pr=$PR

Because each PR's database is independent, ten PRs can run this workflow at once without a shred of coordination — the parallel-CI win falls out for free. And because the fork is disposable, the cleanup job is genuinely all the teardown you need: no truncating tables, no resetting sequences, no "restore staging to a known state" ritual. Delete the VM and the entire database goes with it.

Per-PR database vs. shared staging vs. docker-compose-in-CI

There are three common ways to give tests a database, and they trade isolation, realism, and speed differently. Laid side by side:

  • Isolation — Per-PR forked database: complete; each PR gets its own VM, writes are private, nothing leaks. Shared staging DB: none; every PR and CI run sees every other's writes, and destructive changes affect everyone. Docker-compose Postgres in CI: good within one job, but it's a fresh empty database per run, not a shared realistic one.
  • Setup cost per run — Per-PR forked database: near zero; a sub-second copy-on-write fork of an already-migrated, seeded snapshot. Shared staging DB: zero, because you reuse the same dirty state (that's the problem, not a feature). Docker-compose Postgres in CI: you re-run every migration and re-seed on every job — the exact minutes-per-run tax forking exists to remove.
  • Data realism — Per-PR forked database: high; the golden snapshot is seeded with production-shaped fixtures, so tests hit realistic data and query plans. Shared staging DB: high but drifting and unpredictable, since it's whatever everyone left behind. Docker-compose Postgres in CI: whatever you re-seed each time, usually a thin fixture set because a big one is too slow to load per run.
  • Destructive-migration safety — Per-PR forked database: total; the database exists only for this PR and is about to be deleted, so drop whatever you like. Shared staging DB: none; a risky migration is a shared incident, so it never gets a real dry run. Docker-compose Postgres in CI: safe but unrealistic; you're testing the migration against an empty or thin database, not production-shaped data.
  • Parallelism — Per-PR forked database: unlimited; N PRs, N databases, N jobs at once. Shared staging DB: serialized; concurrent runs corrupt each other. Docker-compose Postgres in CI: parallel, but each job pays full setup and lacks realistic seed data.

Docker-compose-in-CI is the honest competitor here — it does give each job an isolated database. What it can't do cheaply is give each job a realistic one, because loading a production-shaped seed set on every run reintroduces exactly the setup tax you were trying to avoid. Forking a golden snapshot is docker-compose's isolation with staging's realism, minus both of their costs.

When you don't need this

Per-PR databases are real infrastructure, and not every project's threat model calls for them. Skip the pattern, or reach for something lighter, when:

  • Your tests don't touch a real database. If your suite runs entirely against an in-memory store or thoroughly mocked data access and you have no integration tests worth the name, a per-PR Postgres solves a problem you don't have.
  • Your app is small and your schema is trivial. If migrate-and-seed takes two seconds, the setup tax the golden snapshot removes barely exists — a plain fresh database per job (docker-compose or a managed create) is simpler and fast enough.
  • You only ever run one CI job at a time. Without parallelism and without risky migrations, a single well-managed shared database with per-run schemas may be all you need; the fork machinery is overkill.

But the moment you have integration tests that hit Postgres, migrations scary enough that nobody dares run them against staging, and enough PRs in flight that CI serializes on the database — which is to say, the moment you're a normal growing team — per-PR forked databases stop being a luxury and start being the thing that unsticks your build queue and your migration reviews at once. Compute the migrated, seeded state once; fork it per PR in sub-second; throw each copy away when the PR closes. The shared staging database that everyone's afraid to touch can finally be retired, unmourned.

Frequently asked questions

Why give every pull request its own database instead of sharing one staging DB?

A shared staging database is a shared blast radius. Concurrent PRs and CI runs see each other's writes, which breaks test isolation and produces heisenbugs; risky migrations can't be dry-run against it because they'd take everyone down, so the migrations that most need testing never get it; and integration tests that hit the shared DB serialize your CI. A per-PR database removes the sharing: each PR gets a private, real Postgres it can migrate, corrupt, and destroy freely, and N PRs can run their suites in parallel with zero coordination. When the PR closes, the database is deleted and nothing leaks.

How does forking a snapshot avoid re-running migrations and seeds per PR?

The migrated-and-seeded state is identical for every PR — it's a constant you were re-computing every time. PandaStack runs each Postgres as a Firecracker microVM, so you build the golden database once (run every migration, load production-shaped fixtures), snapshot that running VM, and then fork the snapshot per PR. A fork is a copy-on-write clone: it shares the golden memory and disk pages and only diverges when the PR writes, so each PR gets an already-migrated, already-seeded, warm Postgres without redoing any setup. A same-host fork lands in roughly 400–750ms (cross-host 1.2–3.5s), versus 30–90 seconds just to bootstrap a fresh managed database before you've run a single migration.

How do I connect to a per-PR database?

Over SNI. Each database microVM is reachable at a native TLS PostgreSQL connection string of the form postgres://pandastack:<pw>@<id>.db.pandastack.ai:5432/pandastack, where <id> is the sandbox's ID. TLS routing sends the connection to the correct database VM, so wiring stays identical across PRs — you just set your test suite's DATABASE_URL to the connection URL the fork returns. Nothing about the connection setup changes from one PR to the next; only the ID in the hostname does.

What happens to the database when the PR closes, and does anything leak back?

You delete it, and nothing leaks. The per-PR database is a forked microVM that exists only for that PR. Because the fork is copy-on-write, everything the PR wrote — test rows, dropped tables, its own migration — lives only in that fork's private pages and never touches the golden snapshot or any other PR's fork. Deleting the VM on PR close is the entire teardown: no truncating tables, no resetting sequences, no restoring a shared database to a known state. Wire the delete to the pull_request closed event and the cleanup is automatic.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.