Top 11 Ephemeral Postgres Platforms for CI Pipelines: Time to First Connection, and Whether It Is Real Postgres
A CI job has an unusual relationship with a database. It does not care about sustained throughput, connection pooling at scale, replica lag, or any of the things a database vendor's benchmark page is about. It cares about two numbers, and almost nobody publishes either of them.
The first is time to first successful connection: the wall-clock gap between the step that asks for a database and the moment a client gets an accepted TCP connection and a completed authentication handshake, with the server past crash recovery and willing to run DDL. Not "provisioned". Not "status: available". Accepted. Because the very next thing your job does is run migrations, and a migration tool that connects one second too early does not retry gracefully — it fails the build, and it fails it in a way that looks like a flaky test rather than a provisioning race.
The second is whether the thing you connected to is actually PostgreSQL. This sounds like a silly question until a migration does `CREATE EXTENSION pg_trgm`, or a fixture loader uses `COPY ... FROM STDIN`, or an integration test asserts on `pg_stat_statements`, or a CDC test wants a logical replication slot. Several products in this category are wire-compatible rather than Postgres, and wire-compatible means your psql client connects beautifully and your migration fails on statement forty-one.
I build PandaStack, an open-source Firecracker microVM platform with a managed Postgres product in it, so I have skin in this and I will be explicit about where mine loses. It loses on the first number, badly, and I will show you the arithmetic rather than bury it.
The five axes, chosen before the list
- Time to first successful connection, as experienced by a job that must then run migrations. The shape matters more than the median: "a few seconds, every time" and "usually instant, occasionally two minutes while something provisions" are different products even if the averages are close.
- Real Postgres or a dialect. Can you `CREATE EXTENSION`? Does `COPY` work? Can you create a logical replication slot, install an extension the vendor has not allowlisted, or run DDL that needs more privilege than a tenant role gets? Wire compatibility is not the same property and the gap only shows up under load-bearing SQL.
- Isolation between concurrent jobs. Four distinct shapes: one shared cluster everybody writes to, schema-per-job inside one cluster, database-per-job inside one cluster, or an actual separate Postgres process — and in rare cases a separate kernel. This decides whether one job's `VACUUM FULL`, OOM, or stray `DROP SCHEMA public CASCADE` is a local event or everyone's event.
- Teardown, and what a leak costs. Flaky jobs leave things running. Assume it will happen weekly. Is the leftover free, a few cents, or a provisioned instance that bills until a human notices? Deletion is not a transaction anywhere in this category: compute stops, a volume lingers, a DNS record outlives the thing it pointed at.
- Seeded-dataset reuse. If seeding takes eleven minutes, the question is whether you can pay that once and then get a fresh copy cheaply — via copy-on-write branching, a snapshot, or Postgres's own `CREATE DATABASE ... TEMPLATE`. This is the axis that actually decides whether ephemeral databases are affordable at your data size.
The baseline you have to beat
Before any platform, here is the thing every one of them is competing with. It is free, it ships with your CI provider, and for a very large number of teams it is simply the correct answer.
# .github/workflows/test.yml -- the baseline. Free, real Postgres,
# dies with the job. Beat this before you buy anything.
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: ci
POSTGRES_DB: app_test
ports:
- 5432:5432
# THIS is the whole trick. Without a health check the runner
# starts your steps as soon as the container is *running*, which
# is several seconds before postgres finishes initdb and starts
# accepting connections. Your migration step then fails with
# "connection refused" roughly one build in twelve, and someone
# spends a sprint calling it a flaky test.
options: >-
--health-cmd "pg_isready -U postgres -d app_test"
--health-interval 2s
--health-timeout 5s
--health-retries 20
env:
# 127.0.0.1, not "postgres": service containers are reachable by
# hostname only from *container* jobs. On a plain runner you get
# the published port on localhost.
DATABASE_URL: postgres://postgres:ci@127.0.0.1:5432/app_test
steps:
- uses: actions/checkout@v4
# Tuning that is reckless in production and correct in CI: the
# data is thrown away in four minutes, so durability buys you
# nothing and fsync costs you most of your test runtime.
- name: Make it fast and unsafe
run: |
psql "$DATABASE_URL" -c "ALTER SYSTEM SET fsync = off"
psql "$DATABASE_URL" -c "ALTER SYSTEM SET synchronous_commit = off"
psql "$DATABASE_URL" -c "ALTER SYSTEM SET full_page_writes = off"
psql "$DATABASE_URL" -c "SELECT pg_reload_conf()"
- name: Migrate and seed ONCE, into a template
run: |
./bin/db-setup
# Now freeze it. Each test worker will clone this in O(size-on-disk)
# rather than replaying migrations and fixtures again.
psql "$DATABASE_URL" -c "ALTER DATABASE app_test IS_TEMPLATE true"
- name: Test, one database per parallel worker
run: pytest -n 8 # conftest does CREATE DATABASE w_$N TEMPLATE app_test
That workflow contains the two tricks that make most platform shopping unnecessary. The health check removes the provisioning race that people misdiagnose as flakiness. And `CREATE DATABASE ... TEMPLATE` is Postgres's own built-in branching: a file-level copy of an existing database, no extension required, which gives every parallel worker its own real database seeded from a snapshot you paid for once. It has been in Postgres approximately forever and it is still the most under-used feature in testing.
The eleven, by shape
1. `services: postgres` in GitHub Actions or GitLab CI
Shape: a container on the CI runner, on the runner's kernel, inside the job's lifetime. Time to first connection is container pull (usually cached) plus `initdb` plus startup — fast and, more importantly, bounded and predictable. It is genuinely real Postgres: the official image, any extension in it, `COPY`, replication slots, superuser. You are `postgres`, and nobody is going to stop you.
Isolation is a separate process per job because the job gets a whole runner. Leak cost is zero: the runner is destroyed, and with it everything you forgot. Seeded reuse is `TEMPLATE`, or a pre-seeded image layer.
Honest drawback: it is bounded by the runner. A 40 GiB seeded dataset does not fit on a standard hosted runner's disk, restoring a large dump into a fresh cluster on every job is a real cost, and the database cannot outlive the job — so "keep this database around for a human to inspect after the failure" is not available. It also cannot be shared with a preview deployment of the same pull request, which is the single most common reason teams graduate.
2. Testcontainers and Docker Compose
Shape: the same container, but started from inside your test process rather than declared in your CI config, which is the entire point. The database comes up where the test suite runs — your laptop, your CI, a colleague's machine — with one code path. Testcontainers' reuse and snapshot support means you can seed once per suite and reset between tests without a full restart.
Real Postgres, process-per-test-run isolation, zero leak cost when Ryuk reaps the containers at exit. Seeded reuse via image layers, a reusable container, or `TEMPLATE` inside the one you started.
Honest drawback: you need a Docker daemon, which is the one requirement that turns a simple CI job into an interesting one. On a hosted runner you have it; inside a Kubernetes-based CI you are now having the docker-in-docker conversation, with a privileged socket and the security conversation that follows — see Docker-in-Docker vs microVMs for CI builds. The containers also run on the runner's kernel, so a test that OOMs the database can take the runner with it.
3. Template databases on one long-lived cluster
Shape: you keep one real Postgres cluster — a cheap managed instance, or one you run — with a seeded database marked as a template, and every CI job does `CREATE DATABASE job_7f3 TEMPLATE seeded`. This is the fastest option in the entire list for time to first connection, because nothing is provisioned at all: you issue one DDL statement against a server that is already up and already warm.
Real Postgres, obviously. Isolation is database-per-job inside one process: catalogs are separate, but shared buffers, WAL, the autovacuum workers, the connection limit and the CPU are not. Leak cost is a database you forgot to drop, which is disk, which is cheap and still real. Seeded reuse is the whole mechanism and it is excellent.
Honest drawback: one process, shared fate. A job that holds an exclusive lock, a `pg_dump` that pins a transaction for forty minutes and stops autovacuum cleaning up anyone's dead tuples, a connection storm from a parallel matrix that exhausts `max_connections` — these are everyone's problem now. There is also a sharp operational edge: `CREATE DATABASE ... TEMPLATE` requires no other sessions connected to the source, so a stray connection to the template fails the clone, and that failure arrives as a red build in someone else's pull request. You also cannot test anything cluster-scoped, because you do not own the cluster.
4. Embedded Postgres, pg_tmp and friends
Shape: a real Postgres binary, downloaded or vendored, started by your test harness against a temporary data directory, usually on tmpfs, with no daemon and no container. `pg_tmp` and the various embedded-postgres libraries for the JVM, Go, Python and Node all do a version of this. DBngin is the desktop cousin for local development.
It is real Postgres and you are the superuser of your own cluster. Isolation is genuinely a separate process with a separate data directory, which is stronger than database-per-job. Leak cost is a temp directory and a stray postmaster, both of which die with the runner. Seeded reuse: keep a seeded data directory as an archive and untar it, which is crude and extremely fast.
Honest drawback: version and platform matrix pain. You are now shipping Postgres binaries for every architecture your developers and CI use, extensions must be built for exactly that build, and the experience on Apple Silicon versus x86 Linux is a recurring maintenance tax. It is also a per-process answer only — nothing here helps a preview environment or a human debugging a failure after the job ends.
5. Neon branching
Shape: Postgres with the storage layer rewritten — compute separated from a log-structured storage service, so a branch is a copy-on-write pointer into existing storage rather than a copy of your data. This is the entry that made database-per-pull-request a normal idea rather than an expensive one, and the branch creation itself is the fast part of the pipeline rather than the slow part.
It is real Postgres — actual Postgres on top of custom storage, with a supported extension set — and branching is the best answer on the seeded-reuse axis in this entire list, because branch time is essentially independent of dataset size. Isolation is per-branch compute. Leak cost is the branch's storage delta plus whatever the compute does when idle, which is where scale-to-zero matters and where the shape of your CI traffic decides the bill.
Honest drawback: the compute suspends when idle, which is the right default and means the first connection to a cold branch pays a resume. In a CI pipeline that resume lands exactly where it hurts — in front of your migration step — so your client needs a retry with backoff rather than a single connect. The extension set is a vendor allowlist rather than "anything you can compile", which matters if your schema depends on something unusual. I have a longer treatment of the branching model at Best Postgres Database Branching Platforms (2026).
6. Supabase branching
Shape: a whole backend per branch, not just a database. Supabase branching stands up a preview instance wired to a Git branch, with the Postgres plus auth plus storage plus the generated API, and applies the migrations in your repository to it. For a team whose application genuinely is "Postgres and the things Supabase puts in front of Postgres", branching the database alone would be the wrong unit.
Real Postgres, generously extended — this is a product built by people who clearly like Postgres. Isolation is a separate project per branch, which is strong. Seeded reuse depends on how you seed: migrations plus a seed file are replayed per branch, which is correctness-preserving and is not a data-size-independent clone.
Honest drawback: it is branch-of-a-backend, not branch-of-a-dataset, so the create path is heavier and the latency is not CI-step-shaped. It is also the most opinionated entry here: if you wanted a plain Postgres connection string and nothing else, you are buying a platform to get one. Treat the branching feature as something to re-read the docs on before designing around it.
7. PlanetScale for Postgres
Shape: the company that made database branching a mainstream developer workflow — branches, deploy requests, schema diffs as a review artifact — applying that workflow to Postgres rather than only MySQL. The interesting part has always been the process, not the storage: a schema change becomes a reviewable request with a plan, which is a genuinely better way to run migrations than hoping the CI job got it right.
Honest drawback, and it is the reason to read the current docs rather than any blog post including this one: this is the newest Postgres offering in the list, and the shape of what branching, deploy requests and extension support mean on the Postgres side is exactly what has been moving. If you are evaluating it for CI, evaluate the Postgres product specifically and do not assume the MySQL workflow's semantics transfer one-for-one.
8. Xata
Shape: a Postgres platform whose pitch is instant copy-on-write branching of production-sized data, aimed squarely at the problem this post is about — giving every pull request a database that contains realistic data without copying realistic data. The team repositioned from a serverless-data-platform identity to being Postgres-first, so, again, check what you are reading.
Honest drawback: branching production data into a CI environment is a data-governance decision dressed as a developer-experience feature. The moment a pull-request database contains real customer rows, your CI logs, test fixtures, and anyone who can read a build artifact are inside your compliance boundary. The platforms that offer anonymisation on branch are answering the right question; verify how far it goes before you point it at a production dataset.
9. Aurora, RDS and Cloud SQL clones
Shape: the hyperscaler clone verb. Aurora fast cloning gives you a copy-on-write clone of a cluster volume; RDS and Cloud SQL both offer point-in-time restore into a new instance, which is a copy rather than a clone and is priced and timed accordingly. All of them are real Postgres with a vendor-curated extension list, in your VPC, under your IAM, inside the compliance perimeter your auditors already signed.
Isolation is a whole separate instance, which is the strongest shape short of a separate kernel. Seeded reuse is good on Aurora specifically, because the clone is copy-on-write against the shared volume.
Honest drawback: the time axis. Instance-shaped provisioning is minutes, not seconds, which makes this a bad fit for per-job databases and a reasonable fit for per-pull-request ones that live for a day. The leak cost is the worst in this list by a wide margin: a forgotten clone is a provisioned instance billing hourly, with a storage volume behind it, and it will survive your pipeline, your sprint and quite possibly your quarter. If you do this, the cleanup job is not optional and the budget alarm is not optional either. Postgres point-in-time recovery, explained covers what the restore is actually doing.
10. Crunchy Bridge, Railway, Render, Fly Postgres
Shape: a provisioned managed Postgres instance behind a decent API, created and destroyed by a script. Crunchy Bridge is the most Postgres-serious of these — a company of Postgres people, with an unusually honest position on extensions and a genuinely good API — while Railway, Render and Fly make "create a database alongside this preview deployment" a first-class part of their own deploy flow, which is often what you actually wanted. Tembo and Nile sit nearby with extension-stack and multi-tenant-shaped angles respectively, and are worth a look if either of those is your specific problem.
Real Postgres, strong per-instance isolation, extensions that behave like extensions. Seeded reuse is restore-from-backup, which is size-dependent.
Honest drawback: these are instances, not branches. Provisioning is instance-shaped, destruction is your script's job, and a leaked one bills. The PaaS members of this group also tend to couple the database's lifecycle to a deploy environment, which is excellent when your unit of ephemerality is a preview deployment and awkward when it is a test matrix cell.
11. PandaStack managed Postgres
Mine, so let me lead with the loss. A managed database create takes 30 to 90 seconds, because it blocks until Postgres is actually accepting connections rather than returning when a record exists. That is slower than a `postgres:16` container on your runner, by a lot, and it is slower than a branch on any of the copy-on-write platforms above. If your entire requirement is "a database per test job, as fast as possible", I am not the answer and I would rather say that in paragraph one than in the FAQ.
What you get for those 30 to 90 seconds: the database is a dedicated Firecracker microVM with its own kernel, from the `postgres-16` template, baked at 1 GiB of RAM and 8 vCPU. Not a database in a shared cluster, not a container on a kernel your neighbours also use — a separate kernel, a separate network namespace with its own veth pair and tap device, and a durable volume rather than an ephemeral rootfs. It is unmodified PostgreSQL 16 with real extensions, real `COPY`, and real DDL, because there is no storage rewrite and no dialect layer between you and it.
On the seeded-reuse axis, the mechanism is a point-in-time clone into a new database id: the source is untouched, the clone gets its own credentials and its own backup stream, and you can pass a target time to land it at an instant in the past. On the leak axis, databases idle-suspend and wake on connection, so a leaked one stops burning CPU — though memory bills as committed GiB-hours for as long as the database exists, which is the honest half.
Honest drawbacks beyond the create latency: there are three. You do not get a root shell in the database VM — it is a managed Postgres endpoint, not a machine you administer, which rules out anything that needs filesystem access or a custom compiled extension. The RAM tier is fixed at create time because Firecracker cannot resize a snapshot-restored guest, so a resize is a clone onto a different tier rather than an argument. And this is a managed-database product, not a test-harness product: there is no Testcontainers integration, and wiring it into a suite is SDK code you write, which is the next section.
The eleven on five axes
| Option | Time to first connection | Real Postgres? | Isolation between jobs | Leak cost | Seeded reuse |
|---|---|---|---|---|---|
| services: postgres | Seconds, bounded — if you health-check | Yes, official image, superuser | Separate process, runner kernel | Zero — dies with the runner | TEMPLATE, or a pre-seeded image |
| Testcontainers / Compose | Seconds; same path locally and in CI | Yes, superuser | Separate process, runner kernel | Zero if the reaper runs | Reusable container or TEMPLATE |
| Template databases | Fastest here — one DDL, nothing provisioned | Yes, but you do not own the cluster | Database-per-job, one shared process | A forgotten database on disk | The whole mechanism; excellent |
| Embedded Postgres / pg_tmp | Seconds, no daemon needed | Yes, your own cluster, your superuser | Separate process + data directory | A temp dir and a stray postmaster | Untar a seeded data directory |
| Neon branching | Branch is fast; cold compute pays a resume | Yes, with a vendor extension allowlist | Per-branch compute | Storage delta plus idle compute | Copy-on-write; size-independent |
| Supabase branching | Backend-shaped, not CI-step-shaped | Yes, generously extended | Separate project per branch | A preview project you forgot | Migrations plus seed replayed |
| PlanetScale for Postgres | Re-check: newest Postgres entry here | Verify the Postgres-side specifics | Per-branch | Verify current docs | Branch workflow plus deploy requests |
| Xata | Branching pitched as instant | Yes, Postgres-first | Per-branch | Branch storage | Copy-on-write of production-sized data |
| Aurora / RDS / Cloud SQL clones | Minutes — instance-shaped | Yes, vendor-curated extensions | Whole separate instance | Worst here: an instance bills hourly | Aurora clone is CoW; PITR is a copy |
| Crunchy Bridge / Railway / Render / Fly | Instance-shaped provisioning | Yes; Crunchy is the most extension-honest | Whole separate instance | A leaked instance bills | Restore from backup; size-dependent |
| PandaStack | 30-90 s, blocking until it accepts connections | Yes — unmodified PostgreSQL 16 | Separate kernel per database | Idle-suspends; memory bills while it exists | Point-in-time clone into a new id |
Wiring a real one into a test suite
Here is the honest version of the setup and teardown, with the 30-to-90-second create where it actually lands and the clone path that makes the second job cheaper than the first.
# conftest.py -- session-scoped ephemeral Postgres for an integration suite.
#
# Read the latency honestly before you copy this: creating a managed database
# takes 30-90 SECONDS, because create() blocks until Postgres is actually
# accepting connections rather than returning when a row exists. That is
# slower than a postgres:16 service container and it is deliberate -- the
# alternative is handing you a connection string that refuses connections for
# another forty seconds, which turns a provisioning race into a flaky test.
#
# So: ONE database per suite run, not one per test. Per-test isolation is
# CREATE DATABASE ... TEMPLATE inside it, which costs milliseconds.
import os
import subprocess
import psycopg
import pytest
from pandastack import Client
client = Client() # api_key from PANDASTACK_API_KEY
GOLDEN_DB = os.environ.get("CI_GOLDEN_DB_ID") # a long-lived seeded db
@pytest.fixture(scope="session")
def database():
if GOLDEN_DB:
# FAST PATH. A long-lived "golden" database holds the seeded dataset.
# clone() copies it into a BRAND NEW database id from its backup
# stream -- the source is never touched, the clone gets its own
# credentials and its own backups. target_time= would pin it to an
# instant in the past; omit it for "as of now".
#
# wait=True blocks until the clone is running with credentials. It
# replays WAL, so on a large dataset this is minutes, not seconds.
db = client.databases.clone(
GOLDEN_DB,
label=f"ci-{os.environ.get('GITHUB_RUN_ID', 'local')}",
wait=True,
timeout=600,
)
else:
# COLD PATH. A fresh, empty PostgreSQL 16 in its own microVM:
# the postgres-16 template, baked at 1 GiB RAM / 8 vCPU. create()
# already polls until ready, so there is no second wait needed.
db = client.databases.create(
label=f"ci-{os.environ.get('GITHUB_RUN_ID', 'local')}",
timeout=180,
)
try:
# If you ever hold a 202 body instead (clone with wait=False, or
# wake() on a suspended database), this is the call that blocks:
# db = client.databases.wait_until_ready(db["id"], timeout=180)
#
# connection() re-reads the credentials for a running database.
# TLS is required by the proxy -- sslmode=require is not optional.
conn = client.databases.connection(db["id"])
url = conn["connection_url"]
# Belt and braces. The API says it accepts connections; prove it
# before handing the URL to a migration tool that will not retry.
with psycopg.connect(url, connect_timeout=10) as c:
c.execute("select 1")
subprocess.run(["./bin/db-setup"],
env={**os.environ, "DATABASE_URL": url}, check=True)
yield url
finally:
# Unconditional, in a finally, not in an except. A flaky job that
# skips this leaves a microVM alive: it idle-suspends so the CPU
# line goes to roughly nothing, but memory bills as committed
# GiB-hours for as long as the database EXISTS. delete() is
# irreversible and that is the point.
client.databases.delete(db["id"])
The shape to notice is that the slow call happens once per suite, not once per test. A 30-to-90-second create amortised across a 12-minute integration suite is noise; the same create per test case is a different build system. Inside the database, per-test isolation is `CREATE DATABASE ... TEMPLATE`, exactly as in the container baseline — the mechanism does not change just because the server moved.
And the migration step itself, since this is the step every provisioning race breaks:
#!/usr/bin/env bash
# bin/db-setup -- migrate + seed whatever ephemeral Postgres the
# pipeline handed us. Works for a service container, a branch, or a microVM.
set -euo pipefail
: "${DATABASE_URL:?DATABASE_URL is required}"
# 1. NEVER trust "the API said it is ready". Every platform in the roundup
# has some version of a window where the control plane says yes and the
# postmaster says no -- a cold branch resuming, a cluster finishing crash
# recovery, a proxy that accepted the TCP connection on the server's
# behalf. Retry the HANDSHAKE, with backoff, and fail loudly.
for attempt in $(seq 1 30); do
if psql "$DATABASE_URL" -qtAX -c 'select 1' >/dev/null 2>&1; then
echo "connected on attempt ${attempt}"
break
fi
if [ "$attempt" -eq 30 ]; then
echo "never accepted a connection; this is a provisioning failure," >&2
echo "not a flaky test -- do not add a retry to CI and move on" >&2
exit 1
fi
sleep 2
done
# 2. Prove the server is the dialect you think it is, BEFORE migration 41
# discovers it is not. On real Postgres both of these succeed; on a
# wire-compatible service one of them is where your build dies.
psql "$DATABASE_URL" -qtAX -c 'show server_version'
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
create extension if not exists pg_trgm;
create table _probe(id bigint);
copy _probe (id) from stdin;
1
2
\.
drop table _probe;
SQL
# 3. Now migrate. ON_ERROR_STOP is the difference between a failed build
# and a half-migrated database that fails forty tests mysteriously.
migrate -database "$DATABASE_URL" -path ./migrations up
# 4. Seed, then freeze as a template so each parallel worker clones it.
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f ./fixtures/seed.sql
The honest limits on my side
- Create is 30 to 90 seconds and will not be two. It blocks until PostgreSQL is accepting connections, which is the right contract and is still slow. For per-test-case databases, use `TEMPLATE` inside one of mine, or use a container. For per-job databases where the job runs for ten minutes, the create is noise.
- A database is one microVM, so the RAM tier is fixed at create time. Firecracker cannot change vCPU or RAM when restoring a snapshot, so the per-request size is overridden to the baked values — `postgres-16` is 1 GiB and 8 vCPU. Changing it is a clone onto a different tier, not an argument you pass.
- Idle is cheap, not free. The database idle-suspends and wakes on connection, so CPU bills on seconds actually burned, but memory bills as committed GiB-hours for as long as the database exists. At $0.0162 per GiB-hour and a 1 GiB tier, a database you leaked on Friday and noticed on Monday cost you about five cents. That is a rounding error compared with a forgotten cloud instance, and it is not zero, and the fix is still the `finally` block.
- There is no Testcontainers provider, no `services:` integration, and no CI plugin. Wiring this into a suite is SDK code, as above. If what you want is a one-line change to a workflow file, the container baseline is a one-line change to a workflow file.
- Restoring a snapshot used to leave the guest clock frozen at bake time, which made restored guests fail TLS handshakes with certificate-not-yet-valid errors — a genuinely confusing incident when the thing refusing the handshake is your own database proxy. The clock is force-synced on restore, resume and wake now, but it is the kind of bug worth knowing exists in any snapshot-based system you evaluate, including the ones that have not hit it yet.
- Egress from a sandbox is open by default. Sibling sandboxes cannot reach each other's subnets and the cloud metadata range is dropped at the host, but there is no default-deny, so if your CI jobs run code from forks, the rules that stop a test from phoning home are still yours to write — see Controlling Network Egress for Untrusted Code.
Picking one
- Your seeded dataset fits comfortably on a runner and the database does not need to outlive the job: `services: postgres` with a health check, plus `CREATE DATABASE ... TEMPLATE` for parallel workers. Stop here. This covers more teams than the rest of the list combined and costs nothing.
- You want the same database path on a laptop and in CI: Testcontainers, assuming you have a Docker daemon you are allowed to use.
- Seeding is the expensive step and the data is huge: a copy-on-write branching platform — Neon, Xata, or Aurora's fast clone if you are already on Aurora. Grade them on cold-start-before-migrations and on how the extension allowlist meets your schema.
- The unit of ephemerality is a preview deployment rather than a test job: whichever platform already deploys your app — Supabase if your backend is Supabase, or the Railway/Render/Fly tier — so one lifecycle governs both.
- You need the database inside a VPC your auditors already approved: the hyperscaler clones, with a cleanup job and a budget alarm you write on day one rather than after the first invoice.
- You need a separate kernel per database, unmodified Postgres with real extensions, point-in-time clones into new ids, and scale-to-zero when idle — and you can absorb a 30-to-90-second create: mine. That is a narrow set of requirements and I would rather you recognise yourself in it than be talked into it.
The bottom line
Benchmark the two things your pipeline actually experiences. Time how long it takes to get an accepted connection from a cold start, measured at the client, including the retry loop you will have to write anyway. Then run a script that does `CREATE EXTENSION`, a `COPY ... FROM STDIN`, a logical replication slot and whatever DDL your migrations rely on, and see which of them the platform refuses. Those two experiments take an afternoon and will eliminate most of this list for you faster than any comparison table, mine included.
My own position is deliberately narrow: a dedicated microVM per database is the slowest create in this roundup and the strongest isolation boundary in it, which is a trade that makes sense when the thing on the other side of the connection is a pull request from someone you have never met, and makes no sense at all when it is your own unit tests. If your CI suite is happy with a `postgres:16` container on the runner, use the container. It is free, it is real Postgres, it dies when the job dies, and nothing I sell will make your test suite faster than that.
Frequently asked questions
What is the fastest way to get a Postgres database per CI job?
Measured as time to first successful connection, the fastest option is not a platform at all — it is `CREATE DATABASE job_x TEMPLATE seeded` against a Postgres cluster that is already running. Nothing is provisioned, nothing boots, no container is pulled: you issue one DDL statement against a warm server and get a fully seeded database back. The second fastest is a `postgres:16` service container or a Testcontainers-managed container on the runner, which costs an `initdb` and a startup and is bounded and predictable. Copy-on-write branching platforms are extremely fast at the branch operation itself, but in a CI pipeline you have to measure the thing your job experiences, which is branch creation plus the cold compute resume that usually follows it — and that resume lands directly in front of your migration step. The trap in all of these is the same: the control plane reporting readiness before the postmaster accepts connections. Whatever you pick, retry the authentication handshake with backoff rather than trusting a status field, and when the retries are exhausted, fail the build loudly as a provisioning error instead of letting it look like a flaky test. That one loop removes most of what teams misdiagnose as database flakiness.
How do I tell whether a Postgres-compatible service is actually Postgres?
Run a probe script before you trust it, because `psql` connecting successfully proves only that the wire protocol is implemented. Four statements separate real Postgres from a compatible service in practice. First, `CREATE EXTENSION pg_trgm` or whatever your schema actually needs — many platforms support extensions from a vendor allowlist, which is fine until your dependency is not on it. Second, `COPY ... FROM STDIN`, because bulk fixture loading is where test suites spend their time and a service that only speaks `INSERT` changes your seeding story. Third, create a logical replication slot, if you have any CDC, outbox, or change-stream behaviour under test. Fourth, whatever cluster-scoped or privileged DDL your migrations contain — tablespaces, event triggers, roles, `ALTER SYSTEM`, anything needing superuser. Note that a database-per-job inside somebody else's shared cluster fails the fourth category for a completely different reason: the server is real Postgres, but you are not its superuser. Write the probe as a file in your repository, run it as the first step after provisioning, and you will find out in ten seconds rather than at migration forty-one on a Friday afternoon.
What isolation do concurrent CI jobs actually get from each other?
There are four shapes and they are genuinely different. One shared cluster that every job writes to is not isolation at all — it is a convention, and conventions lose to a test that forgets to clean up. Schema-per-job inside one database separates names but shares the database's catalogs, its connection limit, its autovacuum and its locks. Database-per-job inside one cluster is much better — separate catalogs, separate data — but still one postmaster, so shared buffers, WAL, CPU and `max_connections` are common property, and one job's forty-minute transaction stops autovacuum reclaiming anyone's dead tuples. A separate process per job, which is what a container or an embedded Postgres gives you, isolates the database but not the kernel: the processes still share the runner's kernel, its page cache and its OOM killer, so a test that balloons the database can take the runner down with it. A separate kernel per database — which is the PandaStack shape, one Firecracker microVM each — is the only version where the failure domain of one job's database genuinely stops at that job. Whether you need that depends entirely on whether the code driving the database is code you wrote.
What does a leaked ephemeral database actually cost?
It ranges across four orders of magnitude, which is why this axis deserves its own column. A container on a hosted CI runner costs nothing when leaked, because the runner is destroyed and takes everything with it — that is the single strongest argument for the baseline option. A forgotten database inside a shared cluster costs disk and some autovacuum attention. A forgotten branch on a copy-on-write platform costs its storage delta plus whatever its compute does when nobody is connected, which is where scale-to-zero earns its keep. A forgotten clone of an RDS or Aurora cluster is a provisioned instance billing by the hour with a storage volume behind it, and it will outlive your pipeline, your sprint and possibly your quarter — this is the one that shows up in a finance review as a line item nobody can explain. On PandaStack a leaked database idle-suspends, so CPU bills on seconds actually burned, but memory bills as committed GiB-hours for as long as the database exists: at $0.0162 per GiB-hour on a 1 GiB tier, a leak from Friday to Monday costs about five cents. Cheap is not the same as free, and the fix on every platform here is the same unconditional teardown in a `finally` block, plus a scheduled sweeper for the times the `finally` never ran because the runner was killed.
Can I reuse a seeded dataset without re-seeding it for every job?
Yes, and this is the axis that decides whether ephemeral databases are affordable at your data size, because every other cost in this post is fixed while seeding scales with your fixtures. There are four mechanisms. Postgres's own `CREATE DATABASE ... TEMPLATE` is a file-level copy of an existing database with no extension and no platform required — seed once, then clone per worker in time proportional to size on disk rather than to the number of migrations and fixtures. It has one sharp edge: no other session may be connected to the template, so a stray connection fails the clone and surfaces as a red build somewhere unrelated. Copy-on-write branching makes clone time largely independent of dataset size, which is the whole reason the branching platforms exist and the right answer when the dataset is genuinely large. Snapshot-and-restore gives you a frozen machine rather than a frozen database — on PandaStack a point-in-time clone lands the source's data in a brand-new database id with its own credentials and its own backup stream, leaving the source untouched. And the crude one that works better than it should: keep a seeded data directory as a tarball and untar it into an embedded Postgres. Pick whichever matches your size, but do pick one. Replaying migrations and fixtures per job is the cost that quietly makes per-pull-request databases look unaffordable.
Keep reading
- Top 5 scratch Postgres platforms for CI — The shorter, more opinionated sibling: why a tuned container plus TEMPLATE beats most platform purchases.
- How to branch a Postgres database for a pull request — The per-pull-request shape in detail, including what the preview environment needs from the database.
- Managed Postgres on Firecracker microVMs — What is actually inside the 30-to-90-second create: the template, the volume, and the readiness gate.
- Postgres scale-to-zero and idle auto-suspend — Why a leaked database costs cents rather than dollars, and what the wake-on-connect path actually does.
- Sandboxing untrusted Postgres extensions — The other half of "real Postgres": what an extension can do to the server once you let one in.
Related posts
- Top 6 Burner Postgres Platforms for Pull Requests (2026)
A scratch database for a test run dies with the process that made it. A pull-request database has to survive nine days of review, serve a preview app and a human with psql, be found again by the next CI run, and then be deleted by a job nobody owns. Six platforms, graded on the fourth one.
- Top 9 Temporary PostgreSQL Database Platforms (2026)
A temporary Postgres is produced in one of four ways — a process on the runner, a schema inside somebody else's server, a copy-on-write branch, or a server of its own. The factory decides everything else, including whether your migration rehearsal means anything.
- Top 7 Disposable Postgres Platforms for Developers (2026)
Three of the seven ways to get a disposable Postgres are not products you buy. Here are all seven shapes graded on time-to-DSN, isolation boundary, whether they branch real data, and the failure mode each one actually has.
- Wiring preview environments into pull requests
Reading a diff tells you whether the code is reasonable. A URL tells you whether the feature works. Getting from one to the other is about a hundred lines of CI and one decision about databases.
- Top 10 Throwaway psql Platforms for Engineers (2026)
Everyone benchmarks create latency. Nobody benchmarks the part engineers actually wait on, which is the seed. A database you can create in two seconds and must then load 40 GB into is a twenty-minute database -- so grade these ten on the provisioning verb instead.
More in Ephemeral databases · See Ephemeral Postgres databases on PandaStack
49ms p50 cold start. Fork, snapshot, and scale to zero.