Skip to content

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.

Payload blog4 min readships at docs/solutions/blog-payload/migrations-on-vercel.md

Tags: payload · vercel · migrations · deployment · postgres

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:

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:

-- 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:

    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

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.