You deploy to Vercel and the site 500s with:
PrismaClientKnownRequestError:
The table `public.project` does not exist in the current database.
The table exists locally. The migration is committed. The build succeeded. Nothing applied it, because nothing was ever told to.
Vercel builds your code. It does not know your database exists.
Where migrations belong
Run them in the build command, before next build:
bun run db:generate && bun run db:migrate:deploy && bun run build
Set that in Vercel under Settings → Build & Development Settings → Build
Command (override it), or in vercel.json:
{
"buildCommand": "bun run db:generate && bun run db:migrate:deploy && bun run build"
}
Three commands, in this order, for three reasons:
db:generate (prisma generate) because the client is generated code that
is not in git. Prisma 7 writes it to the folder the generator block names (in
this repo src/generated/prisma, which .gitignore lists). This repo also runs
generate from postinstall, which covers every clone and every cold CI job,
but an install that is a no-op on a cache hit may not run lifecycle scripts at
all. Then the build either cannot find the client or typechecks against one
generated from an older schema, so the build command says it out loud.
db:migrate:deploy (prisma migrate deploy) because it is the only
migration command that is safe to run unattended. It applies pending migrations
in order and stops on the first failure. It never prompts, never resets, never
generates a new migration.
build last so a failed migration fails the deploy before any new code
goes live.
The commands that must never run here
prisma migrate devcompares the schema to a shadow database and can offer to reset. Unattended, against production, that is catastrophic. It is a development command, full stop.prisma db pushapplies the schema with no migration history. It resolves differences by dropping columns and tables, silently. It is for a local scratch database while you are still shaping a model.- Manual
psqlDDL puts the database out of step with_prisma_migrations, and the nextmigrate deployreports drift.
Migrations must not need DATABASE_URL to be the pooled one
migrate deploy connects with the URL in prisma.config.ts, never the one the
app's driver adapter uses, so the two can differ:
// prisma.config.ts
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: { url: process.env.DIRECT_URL }, // direct, unpooled
});
Prisma 7 loads no .env file by itself, and Vercel does not need one: it sets
the variables in the build environment. On your laptop, prisma.config.ts has
to load .env.local itself; this repo's does.
The direct string must exist in Vercel for every environment: Production, Preview and Development. The most common "it works in production but every preview deploy fails" cause is a direct URL that was only added to Production. A migration through a transaction pooler hangs on an advisory lock rather than failing cleanly, so the error you get is a timeout with no useful detail. (This repo's config falls back to rewriting the pooled Neon or Supabase host when the direct string is missing, which saves you on those two providers and nowhere else.)
Design migrations for a rolling deploy
Even with the ordering above, there is a window (usually seconds, sometimes longer) where the migration has applied and old instances are still serving traffic with the old code. Any migration that removes or renames something breaks those instances.
The rule is expand, then contract, across two deploys.
Renaming name to fullName:
Deploy 1: expand. Add fullName as nullable. Backfill it. Ship code that
writes both columns and reads fullName ?? name.
ALTER TABLE "user" ADD COLUMN "full_name" TEXT;
UPDATE "user" SET "full_name" = "name" WHERE "full_name" IS NULL;
Deploy 2: contract. Once no running code reads name, drop it and add the
constraint.
ALTER TABLE "user" ALTER COLUMN "full_name" SET NOT NULL;
ALTER TABLE "user" DROP COLUMN "name";
The same shape applies to adding a required column (add nullable → backfill → set not null), and to narrowing a type. Prisma writes single-step SQL by default because it cannot know your deploy strategy: split it yourself with:
bunx prisma migrate dev --create-only
which writes the migration file and lets you edit the SQL before it is applied.
Long migrations and the build timeout
migrate deploy runs inside the build, and builds have a time limit. A
CREATE INDEX on a large table, or a backfill of millions of rows, will hit
it, and a build killed mid-migration leaves a failed row in
_prisma_migrations that blocks every later deploy.
For those, do the work out of band:
- Build indexes with
CREATE INDEX CONCURRENTLYin a manually-run migration (it cannot run inside a transaction, so it needs its own migration file and a deliberate execution), then mark it applied withbunx prisma migrate resolve --applied <name>. - Backfill in batches from a script you run yourself, not from a migration.
Recovering from a failed deploy migration
prisma migrate deploy stops at the first failure and records it. Every
subsequent deploy then fails immediately with "migration failed to apply".
bunx prisma migrate status
Read what it says, fix the SQL, then tell Prisma what actually happened to the failed migration:
# the migration's changes are NOT in the database
bunx prisma migrate resolve --rolled-back 20260401120000_add_project
# you applied the changes by hand and they ARE in the database
bunx prisma migrate resolve --applied 20260401120000_add_project
Then redeploy. Never delete rows from _prisma_migrations to make the error go
away: you are deleting the record of what your database contains.
Preview deployments
Preview branches sharing the production database is the default and it is a
trap: a preview build applies migrations to production. Either point previews
at a branch database (Neon and Supabase both create one per branch) or drop
migrate deploy from preview builds and let production be the only thing that
migrates.
The checklist
- Build command is
generate && migrate deploy && build. DATABASE_URL(pooled) and the direct string (DIRECT_URL, orDATABASE_URL_UNPOOLEDon Neon) set in all three Vercel environments.prisma/migrations/**committed alongside the schema change.- Destructive changes split into expand and contract deploys.
bunx prisma migrate statusclean after the deploy.