Running managed Postgres inside a microVM
Most managed Postgres products put your database in a container on a shared host, or on a shared cluster with your data in a schema alongside other people's. Both work, both are cheap to operate, and both have the same characteristic failure: someone else's query plan goes bad, the host's page cache and I/O bandwidth get consumed, and your p99 doubles for reasons entirely invisible from your side.
PandaStack gives each database a Firecracker microVM with its own kernel, its own page cache, and a durable volume. This post is what that actually means operationally — including the parts that are worse than RDS, because there are some. I build this, so calibrate accordingly.
Why a database wants a VM more than a web app does
For a stateless web service, container isolation is usually fine. Databases are different in three ways that make the shared kernel hurt.
- Postgres is built around the OS page cache. It deliberately keeps shared_buffers modest and relies on the kernel caching the rest of the working set. In a container that page cache is shared with every neighbour, so a neighbour doing a large sequential scan evicts your hot pages and your read latency changes with no change on your side.
- Disk I/O is the resource that actually matters, and it's the hardest to fairly share. cgroup I/O limits are coarse, and a neighbour's checkpoint storm or vacuum can saturate the device beneath you.
- Databases are long-lived and stateful. The blast radius of a host-level problem is measured in data, not in a restart — and 'we lost a node, reschedule the pod' is a very different sentence when the pod owns your data.
A microVM gives the database its own kernel, so its page cache belongs to it. The working set stays resident because nobody else can evict it. That's the single biggest practical difference, and it shows up as latency stability rather than as a headline number.
The architecture
Four pieces, and each one exists to solve a specific problem.
- A baked `postgres-16` template. Postgres is installed and initialised once at bake time; per-database credentials are generated on restore. Bootstrapping Postgres from scratch per database would be slow and would make every database subtly different.
- A durable volume, not the ephemeral rootfs. The rootfs is copy-on-write and disposable; the data directory lives on a volume that survives VM replacement. Getting this wrong is how platforms lose data on a routine restart.
- Persistence flags. A database VM is exempt from the idle reaper that cleans up abandoned sandboxes, and is pinned to its host because its volume is local.
- An SNI-routing proxy. Each database is reachable at a stable hostname; the proxy terminates TLS, reads the hostname from the handshake, and connects you to the right VM. Your connection string never changes even when the VM behind it does.
# Create a database. size picks the RAM tier: 1g (default), 4g, 16g.
curl -sS -X POST https://api.pandastack.ai/v1/databases \
-H "Authorization: Bearer $PANDASTACK_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"label": "acme-prod", "size": "4g"}'
# The connection string is stable for the life of the database.
# TLS is required -- the proxy routes on the SNI hostname.
# postgres://pandastack:<password>@<id>.db.pandastack.ai:5432/pandastackWhy creation takes 30 to 90 seconds
Creating a plain sandbox takes about 179ms. Creating a database takes 30 to 90 seconds, and the gap is worth explaining because it's a design choice rather than a defect.
The API doesn't return until Postgres is actually accepting connections. Restoring the VM is fast; what follows is not instant — provisioning the durable volume, generating and applying per-database credentials, starting Postgres, and waiting for it to pass a real readiness check. We could return in a second with a 'provisioning' status, and the first thing every user would do is poll until it was ready, having first written a connection retry loop that they'd get subtly wrong.
Clone and point-in-time recovery
The operation people underuse: cloning a database into a new one from its archive, optionally at a past moment. The source is untouched throughout — the clone is a new database with a new id.
# Clone the current state into a fresh database.
curl -sS -X POST https://api.pandastack.ai/v1/databases/$DB_ID/clone \
-H "Authorization: Bearer $PANDASTACK_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"label": "staging-copy"}'
# Point-in-time: clone the state as of a moment in the past.
# target_time must be at least a couple of minutes ago.
curl -sS -X POST https://api.pandastack.ai/v1/databases/$DB_ID/clone \
-H "Authorization: Bearer $PANDASTACK_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"label": "before-the-bad-migration",
"target_time": "2026-08-16T09:14:00Z"}'
# Clone into a different RAM tier -- this is the supported resize path.
curl -sS -X POST https://api.pandastack.ai/v1/databases/$DB_ID/clone \
-H "Authorization: Bearer $PANDASTACK_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"label": "acme-prod-16g", "size": "16g"}'Three uses fall out of one primitive. Realistic staging data without a dump-and-restore dance. Recovery from a bad migration by cloning to just before it, verifying, then switching over. And resizing, which is a clone at a different tier rather than an in-place change.
The reason `target_time` must be at least a couple of minutes in the past is mundane and worth knowing: the archive needs the relevant write-ahead log segments to have been shipped. Asking for a moment 10 seconds ago asks for data that may not have reached the archive yet.
What happens when a host dies
Here's where VM-per-database is harder than a shared cluster, and I'd rather say it than have you find out. A database pinned to a host with a local volume cannot be rescheduled elsewhere in seconds the way a stateless container can. Recovery means rebuilding on a healthy host from the archive.
That's what the failover operation does, and getting it right took more than one attempt. Our first version could take a healthy database offline while attempting recovery — the preflight and the action weren't properly separated, so a failed rebuild left you worse off than before. The current shape checks everything it can before touching anything, runs asynchronously, and treats 'the source is still fine' as a reason to abort rather than to continue.
That's the honest trade. You get isolation and a page cache nobody else can evict, and you accept that recovery is a rebuild-from-archive rather than a reschedule. Which side of that you want depends on whether your pain is noisy neighbours or node failure.
Idle databases
Most databases in a multi-tenant fleet are doing nothing most of the time — per-customer databases, staging copies, the one behind an internal tool. Keeping them all running is exactly the always-on cost problem that scale-to-zero solves for apps.
The same idea applies: an idle database can be suspended and woken on the next connection attempt. The mechanics are more delicate than for an app, because a connection arriving at a suspended database has to be held while the VM comes back rather than refused. It's a meaningful cost saving for fleets with a long idle tail, and it's the wrong setting for a database serving production traffic, where you should simply leave it running.
Honest comparison with RDS and friends
- Where RDS wins: multi-AZ failover measured in tens of seconds, read replicas, a decade of operational tooling, deep IAM integration, and a very long list of instance shapes. If you need synchronous replication with automatic failover today, use RDS.
- Where VM-per-database wins: isolation that a security reviewer immediately understands, a page cache that belongs to you alone, latency that doesn't move because of somebody else's query, and per-database creation cheap enough to give every tenant or every pull request its own.
- Where both are the same: you still need to think about connection counts, vacuum, index bloat, and query plans. No hosting architecture saves you from a missing index.
- Where neither wins: if your database is small, low-traffic, and shared with nothing, the cheapest managed Postgres you can find is the right answer, and the isolation argument is a solution to a problem you don't have.
The summary I'd give a friend: VM-per-database is about predictability, not peak performance. You are buying the absence of neighbours — no shared page cache, no shared I/O queue, no shared kernel — and paying for it with a slower create, a recovery path that rebuilds rather than reschedules, and a younger feature set than the incumbents. That's a good trade for multi-tenant products where each customer's data must be genuinely separate, and a poor one for a single application database that's been happy on RDS for three years.
Frequently asked questions
Why run each Postgres database in its own microVM?
Mainly for the page cache and I/O. Postgres deliberately keeps shared_buffers modest and relies on the operating system's page cache to hold the rest of the working set, so in a shared-kernel container a neighbour running a large sequential scan evicts your hot pages and your read latency changes for reasons invisible from your side. Disk I/O is similarly hard to share fairly, since cgroup limits are coarse and a neighbour's checkpoint or vacuum can saturate the device. A microVM gives the database its own kernel and therefore its own page cache, which shows up as latency stability rather than as a higher peak throughput number.
Why does creating a managed database take 30 to 90 seconds?
Because the API waits until Postgres is genuinely accepting connections rather than returning early with a provisioning status. Restoring the VM itself is fast; the remaining time goes to provisioning the durable volume, generating and applying per-database credentials, starting Postgres, and passing a real readiness check. Returning in a second would simply move the waiting into every user's code, where it becomes a polling loop and a connection retry loop that are easy to get subtly wrong.
How do I restore a database to an earlier point in time?
Clone it with a target timestamp. The clone reads from the database's archive and produces a brand-new database at that past state, leaving the source completely untouched — so you can inspect the clone, confirm it has what you expect, and only then switch over. The timestamp must be at least a couple of minutes in the past, because the relevant write-ahead log segments need to have reached the archive first. The same clone operation with a different size parameter is also the supported way to change a database's RAM tier, since a microVM's memory is fixed by its snapshot and cannot be changed in place.
What happens if the host running my database fails?
Recovery is a rebuild on a healthy host from the archive, not a reschedule. This is the genuine downside of pinning a database to a host with a local durable volume: a stateless container can be rescheduled in seconds, and a database with local state cannot. The failover operation performs that rebuild, and doing it safely means checking everything possible before touching anything and treating a still-healthy source as a reason to abort — our first implementation could take a healthy database offline while trying to recover it, which is precisely the failure mode this design has to avoid.
How does this compare to Amazon RDS?
RDS wins on operational maturity, multi-AZ failover measured in tens of seconds, read replicas, IAM integration, and instance shape variety — if you need synchronous replication with automatic failover today, use it. VM-per-database wins on isolation a security reviewer immediately understands, a page cache no neighbour can evict, latency that does not move because of someone else's query, and creation cheap enough to give every tenant or every pull request its own database. Neither saves you from a missing index. And if your database is small, low-traffic, and already happy somewhere cheap, the isolation argument is solving a problem you do not have.
49ms p50 cold start. Fork, snapshot, and scale to zero.