# Payload migrations on Vercel without a broken deploy

> Vercel runs your build, not your migrations. Run them in the build command, forward-only, and split the schema deploy from the code that needs it.

The deploy goes green. The site 500s. The log says:

```
error: column "posts.reading_time" does not exist
```

The migration is in the repository. It was applied on your laptop. Nothing
applied it to production, because nothing was ever asked to.

## Vercel does not run your migrations

A Vercel deploy installs dependencies and runs your build command. That is the
entire lifecycle. There is no release phase, no post-deploy hook, and no
convention that a migration directory means something.

Since `push: false` is set in `payload.config.ts` (and it must be) Payload will
not alter the schema at boot either. That is the correct trade: the schema
changes when a reviewed migration runs, not when a process happens to start.

So you have to run them yourself, and the build command is the only hook you get:

```
bunx payload migrate && bun run build
```

Set that in Project Settings -> Build & Development Settings -> Build Command.
Migrations run first; if one fails, the build fails and the broken deploy never
goes live.

## Which connection string migrations use

Migrations open long-lived sessions and take advisory locks. Transaction poolers
drop both.

If your provider gives you two endpoints (Neon, Supabase and most others do)
the migration must use the **unpooled** one:

```ts
db: postgresAdapter({
  pool: {
    connectionString: process.env.DATABASE_URL_UNPOOLED ?? process.env.DATABASE_URL,
  },
}),
```

with the pooled endpoint in `DATABASE_URL` for everything else. Run a migration
through a transaction pooler and it will hang, or half-apply, or report a lock
error that means nothing to anyone.

## Concurrency: several builds, one database

A push to two branches runs two builds. Both run `payload migrate` against the
same database if both point at production.

Payload takes a lock and records applied migrations in `payload.payload_migrations`,
so the second one waits and then finds nothing to do. That works: as long as
preview deployments do **not** point at the production database. Give previews
their own database or a branch of it. Most managed Postgres providers can
branch a database in seconds, which is the cheapest fix available.

## Deploy in the order the database allows

The dangerous window is between "migration applied" and "new code live". During
it, old instances are still serving traffic against the new schema.

Additive changes are safe: a new nullable column, a new table, a new index. Old
code ignores them.

Destructive and constraining changes are not. Split them across two deploys:

**Adding a required column**

1. Deploy 1: add it nullable, backfill in the migration, ship code that writes it.
2. Deploy 2: a migration that sets `NOT NULL`.

**Removing a column**

1. Deploy 1: ship code that no longer reads or writes it.
2. Deploy 2: the migration that drops it.

**Renaming anything.** Treat it as add, backfill, switch, drop: four steps, two
deploys minimum. And check the generated SQL, because Payload emits a rename as
a drop plus an add, which deletes the data:

```sql
-- generated
ALTER TABLE "payload"."posts" DROP COLUMN "summary";
ALTER TABLE "payload"."posts" ADD COLUMN "excerpt" varchar;

-- what you meant
ALTER TABLE "payload"."posts" RENAME COLUMN "summary" TO "excerpt";
```

## Commands that must never touch production

- `payload migrate:fresh`: drops everything and re-runs from scratch.
- `payload migrate:reset`: rolls every migration back.
- `payload migrate:down`: reverses the last batch. It is a local tool; in
  production, roll forward with a new migration instead.

If a migration is wrong and already applied, write the fix as a new migration.
Editing an applied file gives you two databases with the same migration list and
different schemas, which is the hardest state to debug in this entire area.

## When a migration fails mid-deploy

1. The build failed, so the old code is still serving. Do not panic-deploy over
   it.
2. Find out what actually applied:

   ```sql
   select name, batch, created_at
   from payload.payload_migrations
   order by created_at desc
   limit 5;
   ```

3. Inspect the real schema (`\d payload.posts`) rather than trusting the
   migration file.
4. Fix forward: a new migration that reaches the state you wanted from the state
   you are actually in.
5. Take a snapshot or a branch before retrying on anything with real data.

## Before the first deploy, check three things

```bash
bun run payload:migrate     # applies cleanly on a fresh database
bun run build               # the admin bundle builds
bun run verify              # PAYLOAD_SECRET is set and the CMS answers
```

Then confirm on Vercel that: the build command runs `payload migrate`;
`PAYLOAD_SECRET` and `DATABASE_URL` exist in the production environment;
preview deployments do not share the production database.

---

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
