The databasePart 5 of 6

Deploy is a branch promotion, not a migration

Shipping to production normally means moving data: dump dev, restore into a separate prod database, run the migrations, hope nothing drifted. On copy-on-write Postgres timelines it's a different shape — production is a branch, and going live is a promotion. No bytes move when prod forks from dev's head, re-deploys advance prod and apply only the pending migrations, rollback is a point-in-time branch, and each environment scales to zero independently. Here's the model and the mechanics.

Deploy is a branch promotion, not a migration

The usual path to production moves data. You snapshot dev, restore it into a separate production database, run the migrations there, and hope the two schemas haven't drifted in the meantime. You now operate two databases that are supposed to be the same and slowly aren't, and the copy step gets slower as your data grows.

If your databases are copy-on-write timelines on a shared page store, deploy has a different shape entirely. Production isn't a separate database you copy into — it's a branch. Going live is a promotion: a metadata operation that moves a pointer, not bytes. This post is the model the platform's timeline architecture is built around, and the mechanics underneath it.

It builds on two primitives covered elsewhere: copy-on-write branching at an LSN and scale-to-zero compute. If those are new, skim them first — this post composes them.


The model: prod is a durable branch forked from dev

A project's database is a timeline. Day to day that timeline is the dev environment — where the agent builds and iterates. Production is a second timeline: a durable branch that customer traffic hits.

The first deploy forks dev's current head into a new, durable prod timeline. From that point prod is its own timeline carrying real users' data, and dev keeps running on its own timeline, untouched. Because branching is O(1) copy-on-write, the fork copies no data — a deploy is the same cost whether the database holds a kilobyte or a hundred gigabytes.

first deploy: fork dev head → prod

shared history up to the fork

dev timeline
agent builds here · keeps running

prod timeline
durable · customer traffic

Two consequences fall straight out of "prod and dev are the same engine, one branch apart":

  • No provisioning drift. Prod isn't a separately stood-up database you dump-and-restore into — it's a fork that inherited dev's migration ledger at the split. A deploy applies only the genuinely-pending migrations (the DDL dev ran that prod hasn't), so there's no "works on dev, breaks on prod" gap from two databases provisioned and migrated independently.
  • Preview against real production data. Need to debug a production issue or test a change against prod-scale data? Fork prod into a throwaway timeline and poke at it — copy-on-write, isolated, discarded when you're done. The real prod timeline never feels it.

The promotion mechanics

The first promotion is a single metadata move: fork the dev head into a durable prod timeline. The decision is pure — given the current branches, what becomes prod — and the execution is a branch operation with no data copy:

pageserverneon-conn-proxybackend (promotion)deploy triggerpageserverneon-conn-proxybackend (promotion)deploy triggerpromoteToProduction(project)planPromotion(branches) %% pick the dev-serving timelinefork dev head → prod timeline (O(1), CoW)create_timeline(ancestor=dev head)record prod branch + setServingBranch("prod")provision prod compute (env=prod) for the prod timeline

The promotion logic is a small pure function — planPromotion — that looks at the project's branches and decides which timeline becomes prod (and is a no-op if dev already equals prod). The execution layer forks the dev head, records the new prod branch, marks it serving prod, and provisions a prod compute pointed at it. Rolling back a bad deploy is the same branch-at-LSN primitive as time-travel, applied to the prod timeline: pick an earlier point, fork, repoint — no restore-from-backup.

That's the first promotion. A re-deploy doesn't re-fork: it keeps the live prod timeline — real users' data persists across releases — and advances it. It pins the newly-released commit and applies any pending db/migrations to prod as forward-only DDL (runPendingMigrations({ env: "prod" })), after tagging a pre-deploy LSN checkpoint as a rollback anchor. So a deploy does run schema migrations against prod; what it never does is copy data. The only path that overwrites prod data with dev's is an explicit, separately-snapshotted reset — never a side effect of shipping.


Separate compute per environment — and both scale to zero

Prod and dev have to be live at the same time: customers hit prod while the agent keeps iterating on dev. A single Postgres process is bound to one timeline at boot, so the environments get separate computesneon-compute for dev, neon-compute-prod for prod — each pointed at its own timeline.

That sounds like double the cost, and it would be, except both computes scale to zero independently (the scale-to-zero proxy routes per-environment by database name and wakes each on demand):

  • App with no visitors and nobody building → both at zero.
  • A customer visits → prod wakes; dev stays at zero.
  • The agent iterates → dev wakes; prod stays at zero.

You pay per environment accessed, not per environment provisioned. The manifests are generated per environment from a single template (generateNeonComputeManifests(env)), so a prod compute is the same resource set as dev with a distinct name and timeline.

The materialization is lazy: a project that hasn't deployed has exactly one compute (dev). The prod timeline and its compute come into existence at the first promotion — so the long tail of never-deployed experiments carries no prod overhead at all.


What this gives you

  • Deploy is instant and data-size-independent. It's a fork + a pointer move, not a dump/restore. A 50 GB database promotes as fast as an empty one.
  • No provisioning drift. Same engine, one branch apart — prod was forked from dev and inherited its migration ledger, so a deploy applies only the pending DDL, never a blind full re-run against a separately-built database.
  • Preview on real data. Fork prod to a throwaway timeline, test against production-scale data, discard. Production is untouched.
  • Rollback is point-in-time. Prod is a timeline, so reverting a bad deploy is the same branch-at-LSN operation as code+data time-travel — applied to prod.
  • Idle is free, per environment. Scale-to-zero on each compute means the second environment isn't a standing cost; it's there when accessed and gone when not.

The shape of it

Stop thinking of production as a place you copy your database to, and start thinking of it as a branch you promote. On separated storage/compute Postgres, "deploy" stops being a data-migration event with a copy step and a drift risk, and becomes what it should be: moving a pointer to say "this version is the one customers get." The data was already there — shared, copy-on-write, one branch away from every environment.

Build on the platform these posts describe.

Describe your app in plain English — Adorable writes the code, sets up the database, and ships it live.

Start building free