Vercel builds your app and serves it. It has no "run this before the release goes live" step, no post-deploy job, no ordering guarantee between build and traffic. So the question "where do migrations run?" has several plausible-looking answers, most of which are wrong.
The wrong places
In next.config.ts or a top-level module. Anything at module scope runs
during the build and in every serverless instance at cold start. Your
migration tool will take an advisory lock on every cold start of every instance,
forever. On a warm day that is thousands of lock acquisitions and one very
confused database.
In a route handler, guarded by a flag. Now a request triggers DDL. The request that triggers it times out (migrations are slower than your function limit), the lock is held by a process that has been killed, and the next deploy inherits the mess.
In instrumentation.ts. Better instincts, same problem: it runs per
instance, not per deploy.
Manually from a laptop, after the deploy. This works right up until the deploy that needs the migration to have run first, or the person with the laptop is asleep.
The right place: the build command
DATABASE_URL="$DATABASE_URL_UNPOOLED" <your migrate command> && <your build command>
Set that as the project's Build Command in Vercel (Settings → Build & Development Settings). Concretely, for the two ORMs this battery supports:
DATABASE_URL="$DATABASE_URL_UNPOOLED" bun run db:migrate:deploy && bun run build
for a generated-SQL migrator, or the ORM's own deploy command (prisma migrate
deploy, never migrate dev, never db push).
This gives you three properties that matter:
- It runs once per deployment, not per instance and not per request.
- It runs before the new code serves traffic. If it fails, the build fails and the old deployment keeps serving. A failed migration is a failed deploy, which is exactly the blast radius you want.
- It is visible. The migration output is in the build log, next to the commit that caused it.
Why DATABASE_URL_UNPOOLED is not optional here
Vercel injects DATABASE_URL pointing at Neon's -pooler endpoint, because
that is what the running app needs. A migration run through it will, depending
on your luck: hang waiting for an advisory lock it is holding on another
backend; fail on create index concurrently; or apply statement one and not
statement two.
So the build command overrides the variable for the duration of the migration
only. Both variables must exist in every environment: production, preview
and development. The classic incident is a preview environment that inherited
only DATABASE_URL; its first migration fails with a lock error that never
mentions the missing variable.
If your Neon project is connected through the Vercel integration, both are injected for you, including for preview branches.
The build cache trap
Vercel restores node_modules from cache. For ORMs with a code-generation step
(Prisma), a cached node_modules can contain a client generated against the
previous schema, so the build succeeds and the running app has types and
runtime mappings for columns that no longer exist. Always regenerate in the
build command, before the migration:
bunx prisma generate && DATABASE_URL="$DATABASE_URL_UNPOOLED" bunx prisma migrate deploy && bun run build
Generated-SQL migrators (Drizzle) have no client to regenerate, which is one of the reasons they are simpler here.
The ordering problem nobody warns you about
Even with migrations in the build, there is a window where the new schema is live and the old code is still serving: Vercel does not stop the previous deployment the instant the build finishes, and any in-flight request is still running old code.
So a migration that removes something breaks production even though nothing was deployed incorrectly.
The fix is expand/contract, and it is two deploys, not one:
Deploy 1 (expand). Additive only. Add the new column as nullable. Backfill it. Write to both the old and the new column. Read from the old one. Nothing the old code depends on has changed, so old and new code can both serve.
Deploy 2 (contract). Once every instance is running deploy 1's code, switch
reads to the new column, then in a later migration drop the old one and add the
NOT NULL.
Renames are the same shape: add, dual-write, backfill, switch reads, drop. The temptation to do it in one migration is exactly how you take a production outage for a cosmetic change.
Rehearse on a branch first
Neon branches make the rehearsal free. Before the deploy:
bun run db:branch
DATABASE_URL="$BRANCH_DIRECT_URL" bun run db:migrate
Because the branch is copy-on-write, it has production's row counts and data
distribution. The NOT NULL that fails on existing rows, the unique index that
fails on existing duplicates, the alter column type that takes eleven minutes: all of them show up here, on a database you can throw away.
Rollbacks
Vercel's "instant rollback" reverts code. It does not revert your database. So a rolled-back deploy leaves you with new schema and old code, which is safe if and only if every migration in that deploy was additive.
That is the practical argument for expand/contract even when you are certain your migration is fine: it makes rollback a code-only operation.
If you truly must undo a schema change, write a forward migration that undoes it. Never hand-edit the migrations table.
Checklist
- Build command runs migrations on
DATABASE_URL_UNPOOLED, then builds. - Both connection strings exist in production and preview.
- No migration code at module scope, in a route, or in instrumentation.
- Breaking changes split into expand and contract deploys.
- The migration was rehearsed on a Neon branch with production-shaped data.
- The migration file is committed with the code that needs it.