# The Payload Migrate Hang: a Flag That Did Nothing

- Author: Abdullah Chaudary
- Category: Next.js
- Published: 2026-10-05T16:37:02.106Z
- Updated: 2026-10-05T18:44:31.491Z
- Reading time: 6 min
- Tags: Payload CMS, Postgres, Docker, CI/CD, GitHub Actions

> payload migrate hangs in CI when a dev-mode marker row waits on a y/N prompt no runner can answer. The documented flag does not reach that command. Deleting one row does.

**TL;DR:** A Payload CMS `payload migrate` step that hangs in CI, with Docker and Postgres and no error, is often waiting on a question nobody can answer. If `payload_migrations` holds a row named `dev` with batch `-1`, left there by dev-mode schema push, Payload 3.88 asks "data loss will occur. Would you like to proceed?" and waits for a keypress. The flag that looks like it skips the question, `--force-accept-warning`, is never passed to plain `migrate`. Deleting the row is the fix.

## Why Does Payload CMS Migrate Hang in CI with Docker and Postgres?

`payload migrate` hangs in CI because it stops at an interactive yes/no prompt whenever the migrations table contains a dev-mode marker row, and a CI runner has no one to press a key. Nothing crashes and nothing times out on its own. The step simply waits.

On 2026-09-07 I added a production deploy pipeline to this site: a GitHub Actions workflow that runs `npx payload migrate` against Postgres, then builds the Docker image ([the full pipeline and request path are here](/blog/one-vps-nextjs-payload-cloudflare-nginx)). The first run's migrate step sat for about 17 minutes until the job was cancelled. The log showed the first line of a message, "It looks like you've run Payload in dev mode", and then nothing. The rest of the prompt, with its `(y/N)`, never reached that run's log; on the next run it appeared only when the process was killed.

The question came from this check in `@payloadcms/drizzle`, which runs before any migration file is applied:

```js
if (migrationsInDB.find((m)=>m.batch === -1)) {
    const { confirm: runMigrations } = await prompts({
        name: 'confirm',
        type: 'confirm',
        initial: false,
        message: "It looks like you've run Payload in dev mode, meaning you've dynamically pushed changes to your database.\n\n" + "If you'd like to run migrations, data loss will occur. Would you like to proceed?"
    }, {
        onCancel: ()=>{
            process.exit(0);
        }
    });
```

## Where Does the dev Row in payload\_migrations Come From?

The `dev` row comes from Payload's dev-mode schema push. When Payload connects with `NODE_ENV` set to anything other than `production` and push not disabled, the Postgres adapter pushes the schema straight to the database and inserts a marker row, `name: 'dev'`, `batch: -1`, so later migrations know the schema arrived by push.

The push condition in `@payloadcms/db-postgres` is a single line:

```js
if (process.env.NODE_ENV !== 'production' && process.env.PAYLOAD_MIGRATING !== 'true' && this.push !== false) {
```

Any script or local server that touches the production database without `NODE_ENV=production` can therefore write the row. The row reached this site's production database twice. The first predated the pipeline and sat in `payload_migrations` on both databases the pipeline migrates, left over from an earlier dev-mode schema push. Production's real schema history never came from push at all: it was baselined once by hand, with a single row recording the initial migration, `20260907_192347_initial`, as already run, and every deploy since runs `npx payload migrate` before the Docker image is built. The second appeared minutes after the row was first deleted, when a one-off script ran against production without `NODE_ENV=production`, and the next manual deploy hung on the same prompt for about 12 minutes.

## Does --force-accept-warning Fix the Payload Migrate Hang?

No. `--force-accept-warning` does not fix the hang, because Payload's CLI reads the flag and then never passes it to plain `migrate`. Only `migrate:create` and `migrate:fresh` receive it, and Payload's documentation lists the flag only under `migrate:create`. The CLI does not reject unknown or unused flags either, so the step accepted it without a word.

This is the relevant switch in `payload/dist/bin/migrate.js`, Payload 3.88.0:

```js
switch(args[0]){
    case 'migrate':
        await adapter.migrate();
        break;
    case 'migrate:create':
        try {
            await adapter.createMigration({
                file,
                forceAcceptWarning,
```

The diagnosis behind my first fix was right: the step was waiting on that prompt. The fix was not. With the flag added, the next CI run hung for about 13 minutes. In a local reproduction against a throwaway Postgres 17 container, with the dev row present and stdin closed, `npx payload migrate --force-accept-warning </dev/null` was still waiting when `timeout` killed it at 30 seconds, exit code 124. Closing stdin does not end the prompt either.

## Why Did a Timeout Wrapper Make Things Worse?

A timeout wrapper made things worse because it turned a hang into a green check. The second fix wrapped the step in `timeout 90` and treated exit code 124, `timeout`'s own "I killed it" code, as success, on the theory that migrations had finished and the process simply never exited.

Payload's source says otherwise. Payload's CLI entry point calls `process.exit(0)` as soon as a migrate command returns, so the only thing that keeps it alive is the unanswered prompt. And because the prompt sits in front of the migration loop, nothing had run. The wrapped step went green in 1 minute 41 seconds, after `timeout` killed the unanswered prompt.

The local reproduction made the danger concrete. With one migration pending and the dev row present, the wrapper exited 0 after 113 seconds, and `payload migrate:status` still listed that migration as not run. A green deploy against an unmigrated database is the worst outcome a migrate step can have.

## How Do You Check Whether the dev Row Is There?

Query `payload_migrations` directly, because `payload migrate:status` does not list the marker row. In the local reproduction, the table showed the row plainly:

```text
SELECT name, batch FROM payload_migrations ORDER BY id;
               name               | batch
----------------------------------+-------
 20260907_192347_initial          |     1
 20260907_213905_add_head_scripts |     1
 dev                              |    -1
```

Against the same database with the dev row present and every migration applied, `payload migrate:status` printed a clean table:

```text
│            20260907_192347_initial │     1 │ Yes │
│   20260907_213905_add_head_scripts │     1 │ Yes │
│ 20260917_015139_add_email_settings │     1 │ Yes │
```

Every migration reads Yes, and the row that blocks `migrate` is not in the list. That is why the timeout wrapper's own `migrate:status` check could never catch the hang: a status check passes a database that will still stop at the prompt.

## How Do You Fix the Payload Migrate Hang for Good?

Delete the `dev` row from `payload_migrations`, run plain `npx payload migrate` with no flags or wrappers, and stop the row from coming back by setting `NODE_ENV=production` for anything that connects to the production database.

1. **Delete the marker row** on every database the pipeline migrates: `DELETE FROM payload_migrations WHERE name = 'dev';`
2. **Keep the step plain.** The deploy workflow runs `npx payload migrate` and nothing else, with an inline comment recording both wrong fixes and the real cause.
3. **Guard every connection.** Export `NODE_ENV=production` for any one-off script against production, then check `payload_migrations` for a `dev` row afterwards.

With the row gone, the next CI run applied the pending migration in about 5 seconds, and the deploy after the second deletion logged "Done." from migrate, then passed build, health check and smoke test. The local reproduction agreed: with the row deleted, plain `migrate` exited 0 in 13.6 seconds of wall-clock time, most of it Payload starting up.

The same evening, a second deploy problem showed up once migrations were green: a build that passed every check and still served stale pages, which I wrote up in [Green Deploy, Stale Page: Docker's Layer Cache, Not Next.js](/blog/nextjs-stale-page-after-deploy-docker-layer-cache).

## Limitations

- Every source line here is from Payload 3.88.0, `@payloadcms/drizzle` 3.88.0, `@payloadcms/db-postgres` 3.88.0 and `prompts` 2.4.2. Newer Payload releases were not checked.
- The `prompts` library documents nothing about running without a TTY; the hang is shown by the CI logs and the reproduction, not by documentation.
- The CI durations are the wall-clock times of cancelled or finished runs, not measurements of the prompt itself.

A prompt that waits for a keypress is a hang in any pipeline. How do you catch interactive prompts in your tooling before CI does?

## References

- [Payload CMS: Migrations](https://payloadcms.com/docs/database/migrations): the migrate commands and the flags each one accepts.
- Payload 3.88.0 source in `node_modules`: `payload/dist/bin/migrate.js` (the command switch), `@payloadcms/drizzle/dist/migrate.js` (the prompt), `@payloadcms/drizzle/dist/utilities/pushDevSchema.js` (the marker row) and `@payloadcms/db-postgres/dist/connect.js` (the push condition).
- This site's deploy workflow history (commits `1c47b84`, `6ce94d0`, `4c5977b`, `cb3d2d7`), its CI logs, and a local reproduction against a throwaway Postgres 17 container: the primary source for every timing above.
