---
title: "Run Payload CMS Migrations Before Vercel Build: Safe Rollback Guide"
slug: "payload-migrations-before-vercel-build-safe-rollback"
published: "2026-09-10"
updated: "2026-09-16"
categories:
  - "Next.js"
tags:
  - "Payload CMS migrations"
  - "Vercel build"
  - "Next.js Cache Components"
  - "payload migrate:down"
  - "payload migrate"
  - "database migrations rollback"
  - "SSG build-time queries"
  - "vercel.json buildCommand limit"
  - "Neon unpooled connection"
  - "vercel deployment troubleshooting"
llm-intent: "how-to"
audience-level: "intermediate"
llm-purpose: "Payload CMS migrations: run them before Next.js Cache Components builds on Vercel and auto-rollback failed deploys with migrate:down. Read the tested…"
llm-prereqs:
  - "Payload CMS"
  - "Next.js 16"
  - "Vercel"
  - "Postgres"
  - "Neon"
  - "pnpm"
  - "sh (shell)"
---

**Summary Triples**
- (Run Payload CMS Migrations Before Vercel Build: Safe Rollback Guide, expresses-intent, how-to)
- (Run Payload CMS Migrations Before Vercel Build: Safe Rollback Guide, covers-topic, Payload CMS migrations)
- (Run Payload CMS Migrations Before Vercel Build: Safe Rollback Guide, provides-guidance-for, Payload CMS migrations: run them before Next.js Cache Components builds on Vercel and auto-rollback failed deploys with migrate:down. Read the tested…)

### {GOAL}
Payload CMS migrations: run them before Next.js Cache Components builds on Vercel and auto-rollback failed deploys with migrate:down. Read the tested…

### {PREREQS}
- Payload CMS
- Next.js 16
- Vercel
- Postgres
- Neon
- pnpm
- sh (shell)

### {STEPS}
1. Understand build ordering impact
2. Create a pre-build migrate script
3. Add rollback on build failure
4. Avoid vercel.json and .vercelignore pitfalls
5. Test deploys and inspect API errors
6. Plan destructive schema changes

<!-- llm:goal="Payload CMS migrations: run them before Next.js Cache Components builds on Vercel and auto-rollback failed deploys with migrate:down. Read the tested…" -->
<!-- llm:prereq="Payload CMS" -->
<!-- llm:prereq="Next.js 16" -->
<!-- llm:prereq="Vercel" -->
<!-- llm:prereq="Postgres" -->
<!-- llm:prereq="Neon" -->
<!-- llm:prereq="pnpm" -->
<!-- llm:prereq="sh (shell)" -->

# Run Payload CMS Migrations Before Vercel Build: Safe Rollback Guide
> Payload CMS migrations: run them before Next.js Cache Components builds on Vercel and auto-rollback failed deploys with migrate:down. Read the tested…
Matija Žiberna · 2026-09-10

I'd moved my Payload CMS migrations to run after the production build on Vercel, not before. The reasoning felt sound at the time: if a build fails, I didn't want to have already mutated the database. Preview deployments got the same treatment — no migrations at all, so a PR branch could never touch production data. It shipped, it looked correct, and then the very next production deploy failed outright.

The fix ended up being more interesting than "just move migrate back before build." Here's the full path, including the two infrastructure gotchas that turned a one-line revert into three separate deploys.

## Why the build itself needed the migration first

The app is Next.js 16 with Cache Components turned on:

```typescript
// File: next.config.js
const nextConfig: NextConfig = {
  cacheComponents: true,
  // ...
};
```

That single flag changes what `next build` actually does. With Cache Components, static generation runs against live data at build time — for this app, that means `next build` opens a connection to the production Postgres database and runs real queries while it's compiling pages. It's not deferred to request time; it happens during the build step itself, in the same process that's producing the `.next` output.

That's the detail that broke everything. I'd shipped a Payload CMS field addition — a new `articleSource` option on a Featured Articles block — alongside a migration that adds the backing column. The code for that field was already in the deploy; the column it needed wasn't, because I'd rescheduled `payload migrate` to run after the build instead of before it. `next build` tried to statically generate a page that queried the new column, and Postgres had never heard of it:

```
error: column page__blocks_featured_articles_b.article_source does not exist
```

The build never got anywhere near the migrate step. It died mid-compile, on a query the new code needed but the old schema couldn't answer.

## Putting migrate back first, without giving up the safety net

The obvious fix is to run `payload migrate` before `next build` again. The part worth keeping from the broken version was scoping it to production only, so Preview builds never touch the database — that part of the original change was genuinely correct, just packaged with the ordering mistake.

What I actually wanted was both properties at once: migrate before build, so the build's own queries succeed, and never leave the database migrated behind a build that didn't ship. Payload's CLI makes the second half straightforward — `payload migrate --help` lists a `migrate:down` command, and I checked what it actually does before relying on it:

```javascript
// node_modules/payload/dist/database/migrations/migrateDown.js
export async function migrateDown() {
    const { payload } = this;
    const { existingMigrations, latestBatch } = await getMigrations({ payload });
    // ...
    const latestBatchMigrations = existingMigrations.filter(({ batch }) => batch === latestBatch);
    for (const migration of latestBatchMigrations) {
        // runs migrationFile.down(...) inside a transaction, then
        // deletes the row from payload-migrations
    }
}
```

`migrate:down` rolls back the entire latest batch — every migration that was applied in that run — by calling each migration file's own `down()` function inside a transaction. That's exactly the primitive needed: migrate first, and if the build fails afterward for any reason, undo precisely what was just applied.

That became the build script:

```sh
# File: vercel-build.sh
#!/bin/sh
set -e

if [ "$VERCEL_ENV" = "production" ]; then
  DATABASE_URL="$DATABASE_URL_UNPOOLED" pnpm payload migrate || exit 1
fi

set +e
pnpm payload generate:types && pnpm next build
BUILD_EXIT=$?
set -e

if [ "$BUILD_EXIT" -ne 0 ] && [ "$VERCEL_ENV" = "production" ]; then
  echo "Build failed after migration — rolling back with payload migrate:down"
  DATABASE_URL="$DATABASE_URL_UNPOOLED" pnpm payload migrate:down
fi

exit "$BUILD_EXIT"
```

On production, migrate runs first against the direct (unpooled) Neon connection, so the schema is in place before `next build` starts reading from it. If the build then fails for any reason at all — the migration query, an unrelated compile error, doesn't matter — the script rolls that batch back before exiting non-zero. Preview builds skip both the migrate and the rollback entirely, since `VERCEL_ENV` is never `"production"` there.

Worth being honest about the limit here: this protects an additive migration paired with an unrelated build failure. If a migration ever drops or renames a column that the *currently live* deployment still reads, there's still a window — between migrate succeeding and the build finishing — where the old code is running against a schema it doesn't expect. Rollback shortens that window, it doesn't remove it. Destructive schema changes still need an expand/contract release regardless of build ordering. The migration that triggered this whole investigation only added nullable columns and a foreign key, so it was safe to run ahead of the build — but that's a property of the migration, not of the script.

## Gotcha one: `vercel.json` has a 256-character limit on `buildCommand`

My first attempt put that whole script inline as a one-liner in `vercel.json`, chained with `&&` and `if`/`fi`. It deployed, and immediately showed `Error` status with a two-minute-old timestamp and nothing else useful in the dashboard.

`vercel inspect <url> --logs` came back with zero log lines — not truncated, empty. That was the actual signal: an empty log means the deployment never reached the build step at all. Vercel's config validation was rejecting the deployment before it started. Pulling the deployment record directly from the API confirmed it:

```javascript
// checked via GET https://api.vercel.com/v13/deployments/:id
errorMessage: "The `vercel.json` schema validation failed with the following message: `buildCommand` should NOT be longer than 256 characters"
```

The inline script was 407 characters. The fix was to stop putting logic inside `vercel.json` entirely — `buildCommand` now just calls out to the script file:

```json
// File: vercel.json
{
  "buildCommand": "sh vercel-build.sh"
}
```

Eighteen characters, well under the limit, and the actual logic lives in a real file that can have comments and be tested locally with `sh -n`.

## Gotcha two: `.vercelignore` silently drops the script it's supposed to run

The natural place to put a build script felt like `scripts/`, next to the project's other one-off utilities. That deployed cleanly this time — no config error — and then failed differently:

```javascript
// checked via GET https://api.vercel.com/v13/deployments/:id
errorMessage: "Command \"sh scripts/vercel-build.sh\" exited with 127"
errorCode: "ENOENT"
```

Exit 127 with `ENOENT` means the shell couldn't find the file. The build step had started this time, which ruled out another schema issue — so I checked what actually gets uploaded to Vercel:

```
# File: .vercelignore
# development scripts and data
scripts
seeds
transcripts
```

The entire `scripts/` directory was excluded from the deployment upload, on purpose, for a completely unrelated reason — keeping one-off data migration and toolkit scripts out of the deployed bundle. `vercel-build.sh` inherited that exclusion just by sitting in the wrong directory. Moving it to the repo root, outside anything `.vercelignore` touches, was the actual fix:

```json
// File: vercel.json
{
  "buildCommand": "sh vercel-build.sh"
}
```

Same command, different file location — `vercel-build.sh` now lives at the project root instead of under `scripts/`.

## Confirming it actually worked

With the script in place and reachable, the next production deploy's build log showed the sequence working as intended:

```
[08:09:08] INFO: Reading migration files from ./src/migrations
[08:09:09] INFO: Migrating: 20260914_194817_add_featured_articles_series_source
[08:09:09] INFO: Migrated:  20260914_194817_add_featured_articles_series_source (154ms)
✓ Compiled successfully in 25.1s
```

The migration ran, the schema was in place, and `next build`'s SSG queries against the new column succeeded. Deployment status: `READY`.

The underlying lesson wasn't really about migration ordering — it was that Cache Components makes `next build` a consumer of live production state, not just a compiler. Any build step reasoning that assumes `next build` is schema-agnostic breaks the moment it's turned on. And when a Vercel deployment fails with no useful detail in the dashboard, the deployment's raw `errorMessage` over the API is worth pulling before assuming the failure is anywhere near your own application code.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks,
Matija