# Running migrations on Vercel without a half-applied schema

> Vercel has no migration step, so people add one in the wrong place. Where migrations belong in the build, why the direct URL is mandatory, and how to deploy a breaking change in two safe halves.

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:

1. **It runs once per deployment**, not per instance and not per request.
2. **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.
3. **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:

```bash
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
