Railway makes deploying a Node app almost suspiciously easy: connect a repo, and every push to main builds and ships. But it leaves one question conspicuously unanswered — the question that quietly breaks more deploys than any other: where do your database migrations run?
Not whether — you know you need to run them. Where, and when, relative to the new code going live. Get that wrong and you ship code that expects a column the database does not have yet. Here is the pattern I use, why it works, and the one sharp edge you need to respect.
The pattern: run migrations in the start command
My entire migration strategy lives in one line of railway.json:
{
"$schema": "https://railway.app/railway.schema.json",
"build": { "builder": "NIXPACKS" },
"deploy": {
"startCommand": "npm run db:migrate && npm start",
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10,
"healthcheckPath": "/health",
"healthcheckTimeout": 30
}
}
That startCommand is the whole trick: migrate, then start — as a single boot step. When Railway deploys a new build, it runs npm run db:migrate first; only if that exits 0 does npm start bring the server up. Migrations are guaranteed to have completed before a single request hits the new code.
No separate migration job. No "remember to run migrations" checklist item. No CI step that can drift out of sync with the deploy. The schema is brought current as an inseparable part of starting the app.
Why this beats the alternatives (for small teams)
There are fancier ways to run migrations — a dedicated release phase, a manual gate, a separate worker. They exist because at large scale, coupling migrations to boot has real downsides (more on that below). But for a solo dev or a small team, on-boot migration wins on the axis that matters most: it is impossible to forget.
The failure mode of every "run it as a separate step" approach is human: you deploy, the code assumes the new schema, and you forgot the migration. On-boot migration makes that specific mistake unrepresentable. The migration and the code that depends on it travel together, always.
The migration runner has to be idempotent
On-boot migration only works if running migrations is safe to do on every single boot — including restarts, scale-ups, and crash-loop retries. That means your runner must track what it has already applied and only run what is pending.
The shape is simple: a schema_migrations table recording each applied file, and a runner that lists migration files, diffs against that table, and applies the gap in order inside a transaction:
1. Ensure a schema_migrations(name, applied_at) table exists
2. Read the list of migration files on disk, sorted
3. SELECT the names already applied
4. For each not-yet-applied file, in order:
BEGIN; run it; INSERT its name; COMMIT
Because step 3 short-circuits everything already done, booting a container that is fully migrated is a couple of cheap queries and a no-op. Boot the app a thousand times and the migrations run exactly once each.
The sharp edge: every deploy is now a migration event
Here is the part you have to internalize, because it is the price of the convenience: with this pattern, there is no such thing as a deploy that is not also a migration run. Even a one-word copy change to a template redeploys the app, which re-runs db:migrate. Usually that is a harmless no-op — but it means a trivial change and a risky schema change can end up coupled in the same release.
And a bad migration does not fail gracefully. Look back at that config: restartPolicyType: ON_FAILURE with restartPolicyMaxRetries: 10. If a migration throws on boot, npm start never runs, the container exits non-zero, and Railway dutifully restarts it — re-running the failing migration, up to ten times. You do not get one clean error; you get a crash loop. Meanwhile the healthcheck at /health never goes green, so Railway keeps routing traffic to your previous, healthy deploy (which is the one genuinely good thing happening in this scenario).
How to keep the sharp edge from cutting you
Three habits make on-boot migration safe:
Keep migrations backward-compatible. A migration should not break the currently running version, because for a few seconds during rollout, the old code and the new schema coexist. Add columns as nullable; do not drop or rename a column in the same deploy that stops using it. Split destructive changes across two deploys — expand, then (later) contract.
Rehearse risky migrations on a copy of production data first. "It ran clean on my empty dev database" tells you nothing about the migration that has to rewrite two million rows or add a constraint the existing data violates. Dump prod, run the migration against the dump, watch it succeed there before you let it near a boot sequence.
Don't smuggle a scary migration in with an innocent change. If a migration could fail or take a while, ship it deliberately and watch the deploy — don't let it ride along inside a "fix a typo" commit. The coupling is the pattern's weakness; your discipline is the mitigation.
Add a real health check
The healthcheckPath: "/health" line is doing quiet, load-bearing work, so make the endpoint honest. It should confirm the app can actually serve — including that it can reach the database — not just return 200 because the process is up:
app.get('/health', async (req, res) => {
try {
await db.query('SELECT 1');
res.status(200).json({ status: 'ok', db: 'ok' });
} catch (e) {
res.status(503).json({ status: 'degraded', db: 'down' });
}
});
A health check that only proves "Node is running" will happily wave through a deploy that cannot talk to its database. A health check that pings the database is what lets Railway hold traffic on the old version until the new one is genuinely ready.
The takeaway
On-boot migration is the right default for small projects: migrate && start in your startCommand, an idempotent runner behind it, and a database-aware health check in front. It trades a bit of large-scale flexibility for something more valuable when you are moving fast — you can never forget to migrate, because forgetting is no longer possible.
Just remember the deal you signed: every deploy runs your migrations. Keep them backward-compatible, rehearse the scary ones, and never let a risky migration hitch a ride on a trivial commit.