You run npx medusa db:migrate. It prints Running migrations..., then Creating migrations table 'mikro_orm_migrations'..., then Migrations table created successfully — and then nothing. No error, no stack trace, no timeout. The process sits at roughly 0% CPU indefinitely. There are two different bugs behind that identical symptom, and treating them as one is why most people never fix it. The confirmed cause is that Medusa classifies any DATABASE_URL host that is not literally localhost or 127.0.0.1 as remote and forces SSL on it — and the knex pool underneath is configured with propagateCreateError: false, so a failed connection never rejects, it just stays pending forever. Fix that in medusa-config.ts and you are done today. The second cause, an aarch64-only deadlock in issue #16011, is still open, and Medusa themselves have said the cause is not clear. This guide gives you a diagnoser that tells the two apart, a config builder, and the commands to prove which one you have.
Deploying Medusa on your own infrastructure?
This failure shows up most often on the first production deploy, when DATABASE_URL stops saying localhost and starts saying postgres. The Coolify + Hetzner deployment guide covers the surrounding setup, and the checkout performance guide deals with the next thing that goes wrong once migrations do run.
I have watched this eat an afternoon more than once: the migration works perfectly on a laptop, then stops dead the first time it runs inside a container, and the only difference is the hostname in a connection string. I am Manoj, commerce and ERP implementation lead at Mith Tech in Bengaluru, and we run Medusa deployments where the migration step is the last thing anyone wants to be guessing about.
What the hang actually looks like
The log order matters, because it is the strongest clue you get. In packages/medusa/src/commands/db/migrate.ts the sequence is: Running migrations..., then ensureMigrationsTable() — which logs Creating migrations table 'mikro_orm_migrations'... and Migrations table created successfully — then runModulesMigrations(...), then Migrations completed.
So a hang that always appears to start immediately after the migrations table is created is not a coincidence. The table creation uses the framework's already-established Postgres connection, which worked. The module migrations then open new connections through a different code path — the one that applies the SSL classification. The framework connection succeeded; the module connections never did.
Two very different bugs land you at that same silent screen. The diagnoser separates them:
Answer five questions about your host, your Postgres, your Medusa version, your CPU architecture and where the migrator runs. The result names the likely cause, distinguishes the confirmed SSL failure from the still-unexplained ARM64 case, and gives you the exact command to run next.
Two different bugs produce the identical symptom — Running migrations… then silence at roughly 0% CPU. Answer five questions to find out which one you have.
DATABASE_URL host
Does that Postgres speak TLS?
Medusa version
CPU architecture
Where the migrator runs
Forced SSL?
Yes
Non-localhost host
Mode A (SSL)
Likely
Confirmed, fixable today
Mode B (ARM64)
Ruled out
Open, cause unknown
Confirmed root cause
Mode A — Medusa is forcing SSL on a database that does not speak it
getDefaultDriverOptions() in load-module-database-config.ts matches your DATABASE_URL against /localhost|127.0.0.1|ssl_mode=(disable|false)|sslmode=(disable)/i. Anything else is treated as remote and gets ssl: { rejectUnauthorized: false }. Against a Postgres with no TLS the module connections never complete, and because the knex pool sets propagateCreateError: false the failure never rejects — it just stays pending. Medusa confirmed this classification when closing issue #15987.
Run this next
# Prove it in 10 seconds — no Medusa involved.
psql "$DATABASE_URL" -c "SHOW ssl;" # "off" means the forced-SSL branch will stall- On 2.16.0–2.17.x there is no connection guard at all, which is why this hangs indefinitely rather than erroring.
Mode A — forced SSL on a non-localhost host (confirmed)
This is the common one, and it is fully understood. In packages/core/utils/src/modules-sdk/load-module-database-config.ts, getDefaultDriverOptions() decides your SSL settings by regex-matching the connection URL:
function getDefaultDriverOptions(clientUrl) {
const localOptions = { connection: { ssl: false } }
const remoteOptions = { connection: { ssl: { rejectUnauthorized: false } } }
if (clientUrl) {
return clientUrl.match(
/localhost|127\.0\.0\.1|ssl_mode=(disable|false)|sslmode=(disable)/i
) ? localOptions : remoteOptions
}
return process.env.NODE_ENV?.match(/prod/i) ? remoteOptions
: process.env.NODE_ENV?.match(/dev/i) ? localOptions
: {}
}
Read that carefully. postgres://medusa:medusa@postgres:5432/medusa — a perfectly ordinary Docker Compose service name — does not match, so it takes the remote branch and attempts TLS. A stock postgres:16-alpine has no TLS configured. The negotiation goes nowhere.
Under normal circumstances a connection that cannot be established produces an error. Here it does not, because packages/core/utils/src/modules-sdk/create-pg-connection.ts sets pool: { propagateCreateError: false }. That is a knex/tarn option meaning a pool-creation failure is not propagated to the caller — the raw() call simply stays pending. There is no rejection to catch and nothing to log.
The orchestration on top makes the stall total rather than partial. medusa-app.ts creates a dedicated lock connection with pool: { min: 1, max: 1 }, and for each module opens a transaction that takes pg_advisory_xact_lock(hashtext('db-module-migration:<module>')) before running that module's migrations, with DB_MIGRATION_CONCURRENCY defaulting to 1. One connection, one lock, one module at a time. When the connection never comes up, the whole run stops with zero output rather than failing on one module and continuing.
Medusa's maintainer confirmed the classification directly when closing issue #15987: the issue happens when the URL is not localhost, because that is automatically treated as remote. Issue #15658 is the underlying root-cause report, complete with the source-level regex and the version matrix — and it notes something useful: on 2.14.0 this failed loudly with The server does not support SSL connections, and regressed into a silent hang in 2.15.x.
The fix, and what the default would have done to you
The fix is one block in medusa-config.ts. databaseDriverOptions bypasses the host classification entirely rather than trying to satisfy it — which matters, because ?sslmode=disable in the URL is unreliable. Medusa rebuilds the connection URL from discrete host config in places, dropping the query string, so the marker does not survive into every module path.
Toggle the host and the SSL override and see the resulting medusa-config.ts databaseDriverOptions block, alongside what Medusa's default classification would have forced for that host. Copy the block straight into your project.
Change the host and the SSL override to see the medusa-config.ts block you need — and what Medusa would have forced without it.
DATABASE_URL host
SSL override
Host classified as
Remote
SSL forced by default
Default without override
SSL on
{ connection: { ssl: { rejectUnauthorized: false } } }
Bypasses the classification entirely
databaseDriverOptions wins over the host-matching default, so the Docker service name in DATABASE_URL is no longer a problem. This is the form the reporter A/B-confirmed on the minimal repro in issue #15987.
// medusa-config.ts
import { defineConfig } from "@medusajs/framework/utils"
export default defineConfig({
projectConfig: {
databaseUrl: process.env.DATABASE_URL, // postgres://medusa:medusa@postgres:5432/medusa
databaseDriverOptions: {
connection: { ssl: false },
},
},
})Putting ?sslmode=disable in the URL alone is unreliable: Medusa rebuilds the URL from discrete host config in places, so the query marker does not survive into every module path. The databaseDriverOptions route bypasses the classification instead of trying to satisfy it.
The reporter on #15987 A/B-tested exactly this on a minimal reproduction — node:22-slim, Medusa 2.17.2, postgres:16-alpine reached by Docker service name. Baseline: hangs forever after the migrations table is created. With only ssl: false added and the service name left untouched in DATABASE_URL: completes, exit code 0. Medusa's own Docker installation guide documents the slightly different { ssl: false, sslmode: "disable" } form in its Disable SSL for PostgreSQL Connection section; both work.
Mode B — the aarch64 deadlock, still open
Now the part almost nobody writes down honestly. Issue #16011 reports that on aarch64 Linux — an Oracle Cloud Ampere A1 box, Ubuntu 24.04 — medusa db:migrate deadlocks, while the identical project migrating the identical database from x86_64 (GitHub Actions ubuntu-latest over an SSH tunnel, and x86_64 macOS) completes fine.
What the reporter has ruled out, on his own hardware: Node 20.18, 20.20 and 22 all hang. node:20-slim, node:22-slim, node:20 and node:20-alpine all hang. It reproduces both inside Docker and on the bare host, so it is not a container issue. seccomp=unconfined, UV_USE_IO_URING=0, Docker embedded DNS (tested by direct IP), MTU, telemetry, --skip-links, --skip-scripts and --concurrency 1 made no difference. And critically: databaseDriverOptions.connection.ssl = false did not fix it either. He tested the maintainer's exact snippet and got identical behaviour, EXIT=124 under timeout 120 both runs. Meanwhile medusa start runs fine on the same box with the same DATABASE_URL, serving production traffic. Only the migrator deadlocks.
His own investigation — and I want to be precise that this is the reporter's hypothesis, not Medusa's conclusion — traces an async_hooks chain from runMigrations through executeWithConcurrency down to lockKnex.transaction() and a Promise.then in knex's transaction machinery that never resolves. A Node diagnostic report at hang time shows an idle event loop, no timers, and a single referenced active handle: one idle Postgres TCP socket. On the Postgres side that connection sits state = idle, wait_event = ClientRead.
Medusa's position, stated plainly: the SSL explanation was proposed twice, disproved on that box both times, and the 10-second guard shipped with the explicit note that the version does not fix the issue because it is not clear what the issue is. As of this writing the issue is open, labelled requires-team, with one reporter and no independent reproduction in the tracker.
Two consequences you should carry away. First, on 2.18.0+ the ARM64 case now produces an error that says the timeout "usually indicates an incorrect database URL or an SSL configuration issue" — which in this specific case is wrong, since the migrator had already connected and created the table. Do not let that message send you down the SSL path a second time. Second, the only thing reported to work is running the migration from an x86_64 machine against the same database, then deploying to the ARM host. That is the reporter's production workaround today.
Two things you will see recommended that nobody has actually verified
Running the migrator under --platform linux/amd64 emulation is a plausible inference from "x86_64 works", but nobody in these threads has tested or reported it — and emulated Node and Postgres under QEMU bring their own pathologies. Connection-pool tuning is in the same category: no pool setting is reported to fix either mode. DATABASE_POOL and DB_MIGRATION_CONCURRENCY exist and are worth reading for diagnosis, but the migrator's lock connection is hard-coded to min: 1, max: 1, and --concurrency 1 was explicitly ruled out on the ARM box. Treat both as untested speculation. Changing the Node version is not speculation — it is ruled out, across 20.18, 20.20 and 22.
The diagnosis playbook
Before you change any configuration, find out where the process actually stopped. These are the four places worth looking, in the order that narrows fastest.
Four tabs of runnable commands: pg_stat_activity and advisory-lock queries on the Postgres side, Node diagnostic reports and active-handle inspection on the Node side, the mikro_orm_migrations and script_migrations bookkeeping queries, and the container checks including architecture and OOM.
Four places to look, in the order that narrows fastest. Every command runs as-is against a live hung migration.
state = idle with wait_event = ClientRead means Postgres already answered and is waiting on your Node process — the stall is client-side. state = active with wait_event_type = Lock is a completely different problem.
-- What is every backend on this database actually doing?
SELECT pid,
state,
wait_event_type,
wait_event,
backend_start,
xact_start,
state_change,
now() - state_change AS in_state_for,
client_addr,
application_name,
left(query, 120) AS query
FROM pg_stat_activity
WHERE datname = current_database()
AND pid <> pg_backend_pid()
ORDER BY state_change;
-- Medusa takes pg_advisory_xact_lock(hashtext('db-module-migration:<module>'))
-- per module, so a zombie migrator can still be holding one.
SELECT l.pid, l.locktype, l.mode, l.granted, l.objid, l.classid,
a.state, a.wait_event_type, a.wait_event, left(a.query, 100) AS query
FROM pg_locks l
LEFT JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'advisory'
ORDER BY l.granted, l.pid;
-- Classic blocking, if anything is genuinely blocked.
SELECT pid, pg_blocking_pids(pid) AS blocked_by, state,
wait_event_type, wait_event, left(query,100)
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
-- Destructive: aborts that transaction. Advisory xact locks release with it.
-- SELECT pg_terminate_backend(<pid>);Ask Postgres what it is doing
Run the pg_stat_activity query against the same database Medusa is migrating. Three readings matter. state = idle with wait_event_type = Client and wait_event = ClientRead means the server already answered and is waiting on the Node client — the stall is client-side. state = active with wait_event_type = Lock means you have a genuine database-level lock conflict, which is a different investigation. Zero rows, or only the framework connection, means the module connections were never opened at all — which is exactly what #15658 reports for the SSL case.
Prove or eliminate the SSL classification in thirty seconds
psql "$DATABASE_URL" -c "SHOW ssl;" — if it returns off and your host is not localhost, the forced-SSL branch applies to you. Then reproduce it without Medusa at all: connect through the same pg client with ssl: { rejectUnauthorized: false } and an explicit setTimeout escape hatch. If the script exits with your timeout code rather than resolving or rejecting, you have reproduced the deadlock in ten lines and proved it has nothing to do with Medusa's migration logic.
Confirm the Node event loop is genuinely idle
Start the migration with NODE_OPTIONS="--report-on-signal --report-directory=/tmp/medusa-reports", then kill -SIGUSR2 the hung process from a second shell. Filter the report for active libuv handles. A deadlock looks like exactly one active tcp handle pointing at your Postgres host on 5432, no timer handles, and idle-loop time climbing. If you see pending timers instead, you are in a retry loop, not a deadlock, and the investigation is different. why-is-node-running gives you named stacks if you want them, but the diagnostic report needs no code changes at all.
Read the bookkeeping tables
SELECT to_regclass('public.mikro_orm_migrations') tells you whether you got past ensureMigrationsTable(). If the table exists with zero rows, that is the classic fingerprint of both failure modes. If it has rows, the last one names the module that stalled. And if the hang happened after Migrations completed, look at script_migrations instead — data-migration scripts run in a forked child process, and a row with finished_at IS NULL is a script that started and never finished.
Check the container is what you think it is
printenv DATABASE_URL inside the running container, because environment drift causes more of these reports than any code bug. getent hosts postgres and nc -zv postgres 5432 to confirm resolution and reachability. uname -m inside the container plus docker image inspect --format '{{.Architecture}}/{{.Os}}' to catch an emulated image. And docker inspect --format '{{.State.OOMKilled}}' — an OOM kill looks nothing like a hang, but people misfile it as one.
Apply the fix and re-run
For Mode A, add databaseDriverOptions: { connection: { ssl: false } } and re-run. For Mode B, move the migration to an x86_64 runner with network access to the same database. Either way, verify afterwards by counting rows in mikro_orm_migrations, not by trusting the exit code — unless you are on 2.16.0 or later.
Recovering a migration that died halfway
Medusa's migrator is not wrapped in one giant transaction across all modules. Each module runs inside its own lockKnex.transaction() with a transaction-scoped advisory lock, executed serially. In practice that means completed modules are committed and recorded in mikro_orm_migrations, and the module that was interrupted rolled back with its transaction — its DDL was inside it. A killed migrator is usually not a corrupt state. Verify that before you start deleting rows.
Six ordered steps for a migration that stopped halfway — establish the truth, release any held advisory lock, back up, re-run, surgically remove a single bookkeeping row only when justified, and the supported full reset. Each step carries the exact SQL or shell command with a copy button.
A migration that died halfway is usually recoverable by re-running it. Work top to bottom — the first three steps are evidence and insurance, not repairs.
Steps done
0/6
0% through
Safe to edit rows?
Not yet
Do steps 1–3 first
Re-run idempotent?
Yes
Completed modules are skipped
SELECT id, name, executed_at FROM mikro_orm_migrations ORDER BY id DESC LIMIT 30;
SELECT count(*) FROM mikro_orm_migrations;
SELECT to_regclass('public.script_migrations');
SELECT id, script_name, created_at, finished_at
FROM script_migrations WHERE finished_at IS NULL;On 2.15.x and older, a failed db:migrate could exit 0. If you are pinned below 2.16.0, do not gate CI on $? — assert on the mikro_orm_migrations row count instead.
Two things worth stating outright. Never TRUNCATE mikro_orm_migrations on a database with data in it — Medusa will replay every migration from zero and the first CREATE TABLE against an object that already exists will abort the run. And there is no db:reset command; the supported reset per Medusa's own CLI reference is to drop the database manually and run npx medusa db:setup --db <db_name>.
Which version changes what
One undocumented escape hatch worth knowing, confirmed in the 2.18.0 source and its unit test: MEDUSA_DB_MIGRATION_CONNECTION_TIMEOUT overrides the new 10-second migration connection guard. If you have a genuinely slow-to-accept database and the guard is firing on a migration that would have succeeded, that is the knob.
+Why does medusa db:migrate hang after 'Migrations table created successfully'?
Because Medusa classifies any DATABASE_URL host that is not localhost or 127.0.0.1 as remote and forces SSL on it. Against a stock Postgres container with no TLS, the module connections stall instead of erroring — and the knex pool sets propagateCreateError: false, so a failed connection never rejects, it stays pending. The migrations table was created by the framework's own connection, which already worked, which is why the hang looks like it starts exactly there. Add databaseDriverOptions: { connection: { ssl: false } } to projectConfig in medusa-config.ts and re-run.
+Hey Google, why is my Medusa migration stuck in Docker and how do I fix it?
Open medusa-config.ts, and inside projectConfig add databaseDriverOptions: { connection: { ssl: false } }. Then run npx medusa db:migrate again. Medusa automatically forces SSL whenever your database host is not localhost, and a plain Postgres container does not speak SSL, so the connection stalls silently with no error message.
+Is it safe to re-run medusa db migrate if the first one got killed halfway?
Yes, in almost every case. Each module migrates inside its own transaction, so the modules that completed are committed and recorded in mikro_orm_migrations, while the one that was interrupted rolled back. Back up first, look at the last row in mikro_orm_migrations to see where it stopped, fix the connection problem, and run it again — it skips everything that already ran.
+Which Medusa version fixes the db:migrate hang?
It depends which hang. The Docker and SSL hang is not fixed in any version, because Medusa treats it as a configuration issue rather than a bug — but 2.18.0 (PR #16100) stops it hanging forever and fails in about ten seconds with a DB_ERROR instead. The separate exit-code bug, where a failed migration could exit 0, was fixed in 2.16.0 (PR #15726). The aarch64 deadlock in issue #16011 has no fix in any release and the issue is still open.
+Does medusa db:migrate work on ARM64, AWS Graviton or Oracle Ampere?
medusa start does — the reported ARM box runs production traffic on it fine. The migrator specifically is reported to deadlock on aarch64 Linux while the identical project and database migrate normally from x86_64 (issue #16011, still open, one reporter, root cause officially unknown). The only reported working approach is to run migrations from an x86_64 CI runner against the same database and then deploy to the ARM host. Note that ssl: false does not fix this case; the reporter tested it.
+How do I tell whether a hung db:migrate is stuck in Node or in Postgres?
Query pg_stat_activity on the target database. If the backend shows state = idle with wait_event = ClientRead, Postgres has already answered and is waiting on your Node process — the stall is client-side. Confirm it with a Node diagnostic report: run the migration with --report-on-signal, send SIGUSR2 to the hung process, and inspect the libuv handles. One active TCP socket, no timers, and a climbing idle loop is a deadlock rather than slowness.
+What happens if we don't fix this and just leave the migration hanging?
Nothing good, and nothing dramatic. The process sits there indefinitely at near-zero CPU holding one Postgres connection, and your schema stays at whatever partial state it reached. The real cost is in CI and deploy pipelines: on Medusa 2.15.x and older, a failed migration could exit 0, so a pipeline gated on the exit code would happily deploy application code on top of a schema that was never migrated. That is the failure mode that actually hurts — a green deploy against a half-built database. If you are pinned below 2.16.0, assert on the mikro_orm_migrations row count rather than trusting $?.
+How do I check which migrations already ran in Medusa?
SELECT id, name, executed_at FROM mikro_orm_migrations ORDER BY id DESC LIMIT 20; — that is the MikroORM bookkeeping table, with id serial, name varchar(255) and executed_at timestamptz. Data-migration scripts are tracked separately in script_migrations, where a row with finished_at IS NULL means a script started and never finished. Those scripts run in a forked child process after Migrations completed, so a hang there looks different from a module-migration hang.
A migration that hangs with no error is not a mysterious failure — it is a connection that never rejected, sitting behind a pool option that was chosen for a different purpose. Check the hostname in your connection string and whether the database on the other end speaks TLS, and you will resolve most of these in a minute. If you have ruled that out and you are on aarch64, you are in the open issue, and the honest answer today is to migrate from an x86_64 machine and watch #16011.
Migration hanging on your Medusa deploy?
We deploy and maintain Medusa v2 in production, including the parts that only break the first time you leave localhost. If your migration is stuck and you would rather not spend the afternoon on it, send us the log.