Last updated on

The migration step I stopped running by hand


Part 8 of 8 in the series Running a production database, carefully. Full series ↓

Ever since I put this site's schema on Payload migrations, shipping a change had a manual step in the middle. Merge the PR, then run a command that backs up production, shows me the pending migrations, and waits for me to type y. Recently I deleted that step. Deploys migrate production themselves now. This is the story of why the manual version existed, how the automatic one is built, and what the trade actually cost.

Two earlier posts set this up: adopting Payload migrations without recreating every existing table, and discovering that my preview database was still production. This is the chapter after those, the one where the last human checkpoint goes away.

The step I was removing

The manual command was pnpm db:migrate-prod, and it was built to be hard to get wrong. It backs prod up first, prints the migration status, and refuses to continue without an explicit yes:

==> Target production database: postgresql://…:***@…neon.tech/…
==> Migration status on production (Ran = No means it will be applied):
  20260731_211106_initial_schema .......... Yes
  20260805_142233_add_series .............. No
Apply migrations to the production database above? [y/N]

That prompt was the feature. A backup, a list, and a human deciding this is the moment. For a low-traffic site I run alone, deliberate-and-manual is a defensible default. The schema doesn't change often, and the safest migration is the one a person looked at first.

So why automate away something that worked? Because "a human has to remember" is itself a failure mode. Merge a schema change, deploy the code, and if I don't run the migration at the right time, production is now running code that expects a column the database doesn't have. The safety was real, but the footgun was too.

How it's put together

The whole thing hangs off one script that runs as the Vercel build for every deploy. It looks at two things: which environment is deploying, and whether an opt-in flag is set for that environment. Everything else follows from those two checks.

That structure is deliberate. The same script handles production, preview, and local builds, and it does nothing unusual unless a flag tells it to. The capability ships dark. Merging the code changes no behavior at all, and I turn each path on only after its one-time setup is done and verified. Two flags gate it: RUN_PROD_MIGRATIONS on the production environment, and RUN_PREVIEW_MIGRATIONS on preview.

On a production deploy with the flag on, the build runs four steps in order before it builds anything:

Each step does one job:

  • Snapshot. A small Node script calls the Neon API to create a copy-on-write branch of production named pre-migration-<sha>-<timestamp>, then prunes old snapshots down to a retention count. A Neon branch is instant and full-fidelity and puts no load on prod, which is what makes taking one on every deploy practical. This step is fail-closed: if it can't create the backup, it exits non-zero and the build stops before anything touches the schema.
  • Migrate. payload migrate applies whatever committed migrations production hasn't run yet, in order. Nothing is generated at deploy time; it only runs the files already in the repo.
  • Check. A read-only integrity assertion confirms the migration didn't leave a versioned collection half-built (a real bug I'd hit before, where enabling drafts on an existing collection hides live documents from the admin). If it did, the deploy fails here instead of surfacing days later.
  • Build. Only now does next build run, prerendering pages against the migrated schema.

The preview path is the same idea pointed at a different database. Each preview deploy gets its own Neon branch, an instant clone of production, and migrates that before building. So a PR that adds a table is validated against real Postgres on its own branch, and the change never reaches prod's database until the PR merges.

Two safety nets sit outside the deploy entirely. A CI job proves, on every PR, that the committed migrations apply cleanly from an empty database and then fully reproduce the config, which catches a migration authored against a polluted local database before it can merge. And because previews already run each migration against their own branch, by the time a migration reaches the production deploy it has run against real Postgres twice.

I flipped the flag and then checked the only thing worth trusting. Not that the setting was set, but that deploys were actually producing backups:

$ neon branches list --project <your-project-id>
pre-migration-8ad8e1d-2026-08-12T04-05-…
pre-migration-bed701b-2026-08-12T04-01-…
pre-migration-b6ed094-2026-08-12T03-43-…
pre-migration-53cd5ec-2026-08-12T03-40-…
pre-migration-3b7cfe7-2026-08-12T03-30-…

One labeled backup branch per deploy, each stamped with the commit that triggered it.

What I gave up

Automating the migration meant deleting the checkpoint I'd deliberately built, and a few things left with it.

The human read. Nothing now shows me the pending list and waits. There's no last chance to say "not this one, not yet." A bad migration on main applies on the next deploy whether I'm watching or not.

Control over timing. Migrations used to run when I chose. Now they run when the deploy runs, in the order PRs land on main, and that's no longer mine to schedule.

A different rollback. The manual path took a full pg_dump. The automatic path takes a Neon branch instead. The branch is arguably the better snapshot, instant and with no load on prod, but recovering means restoring production from that branch in the Neon console rather than replaying a down migration. In practice these migrations are forward-only, and the labeled snapshot is the net.

What I got back

No forgotten step. The migration applies exactly once, in merge order, inside the same deploy that ships the code depending on it. The whole "deployed the code but forgot the schema" category is gone.

A backup I can't skip. By hand, --skip-backup is one flag away and "I already took one" is a story I could tell myself. The automated path is fail-closed: no snapshot, no migration. And because the snapshot is a copy-on-write branch rather than a dump, it's cheap enough that skipping it was never worth it anyway.

Less friction, which counts for more than it sounds like. When the safe path is also the default path, I stop postponing the small migrations.

The sharp edges

Automating this moves the risk around rather than removing it, and a couple of things are load-bearing now that weren't.

Expand-first stops being advice and becomes a rule. The migration runs, then the new build takes over, and in the gap (plus any old instance still draining) the old code is talking to the new schema. Drop a column or rename a table and that old code breaks. Every migration has to be additive and backward-compatible. The destructive cleanup waits for a later deploy, once nothing references the old shape.

Backups rotate. The pruner keeps the five most recent. A migration whose damage I don't notice until five deploys later may have lost its restore branch, so Neon's point-in-time retention is the second net underneath. Worth widening that window before leaning on any of this.

Deploys now depend on the Neon API. Fail-closed means a Neon API hiccup fails the build. That's the right trade, since I'd rather a deploy fail than a migration run unprotected, but it's a new coupling to accept on purpose.

Secret hygiene gets more real. The backup step needs a Neon API token living on the production environment: a new long-lived credential to scope narrowly and rotate. Automating a step that touches prod usually means handing the pipeline a secret it didn't need before, and that's worth an audit of where your tokens actually live, not just where you meant to put them.

What I'd tell myself before doing it again

Ship the capability off. Both auto-migrate paths stay dark until an env flag turns them on. Merging the code changes nothing. You flip the flag only after the one-time setup is done and checked. It also makes the decision reversible for free: turn the flag off and you're back to manual.

Fail closed on anything that touches prod. No backup, no migration. No preview branch database, refuse rather than risk migrating prod through a fallback connection string. Every default should lean toward don't when something is ambiguous.

Verify with artifacts, not settings. I didn't believe it worked because a flag was set. I believed it because there were five backup branches with the right commit SHAs on them. When you automate something that touches production, make it leave evidence, then go read the evidence.

Comments

No comments yet. Be the first to comment.

Leave a comment