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
- Deploy 1: add it nullable, backfill in the migration, ship code that writes it.
- Deploy 2: a migration that sets
NOT NULL.
Removing a column
- Deploy 1: ship code that no longer reads or writes it.
- 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
The build failed, so the old code is still serving. Do not panic-deploy over it.
Find out what actually applied:
select name, batch, created_at from payload.payload_migrations order by created_at desc limit 5;Inspect the real schema (
\d payload.posts) rather than trusting the migration file.Fix forward: a new migration that reaches the state you wanted from the state you are actually in.
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.