0%

0000000

0x00

The Payload Migrate Hang: a Flag That Did Nothing

A migration waiting for a keypress nobody can press. The prompt Would you like to proceed (y/N) with a waiting cursor, and a large 17 min.

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

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:

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:

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:

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:

│            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.

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