By the end of this you will have drizzle migrations that run themselves on every Railway deploy, against the same DATABASE_URL your app uses, and that fail the deploy instead of crash-looping your service when the SQL is wrong. You will also know why the Redeploy button did not run your new migration, and what to do when you need an index on a table that is already too big to lock.
Prerequisites: a Node 18+ TypeScript app using drizzle-orm and drizzle-kit, a Railway project with a service deploying from GitHub and a Postgres database in the same project, DATABASE_URL referenced on the service, and drizzle-kit push currently working from your laptop. Roughly twenty minutes.
The whole thing rests on two source-of-truth artifacts: your schema file, and the drizzle/meta/_journal.json plus snapshot pair that generate writes next to each SQL file. Keep those honest and Drizzle is boring. Every failure below is one of the two being broken.
What drizzle-kit generate writes, and why the meta folder is not optional
drizzle-kit generate does not connect to your database. It reads your TypeScript schema, diffs it against the most recent snapshot in drizzle/meta/, and writes three things: the SQL for the difference, a new snapshot of the current schema, and a new entry in the journal.
drizzle/
0000_broad_black_bolt.sql
0001_add_events_created_at.sql
meta/
_journal.json
0000_snapshot.json
0001_snapshot.json
A journal entry is small:
{ "idx": 1, "version": "7", "when": 1757203200000, "tag": "0001_add_events_created_at", "breakpoints": true }
tag is how the SQL file is found. when is the part that decides everything.
Set it up once:
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
out: "./drizzle",
dbCredentials: { url: process.env.DATABASE_URL! },
});
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:check": "drizzle-kit check"
}
}
Run npm run db:generate after a schema change and commit all of it: the .sql file, the snapshot, and the journal diff. Committing the SQL without the snapshot is the single most common way to break this, because the next generate will diff against a stale snapshot and re-emit SQL you already applied.
Push versus generate, decided once
drizzle-kit push applies the diff straight to the connected database and writes no files. The Drizzle docs call it "the best approach for rapid prototyping" and document a --force flag whose job is to auto-accept data-loss statements. That flag is the tell.
Push is fine on your laptop and on throwaway branch databases. It has no place on Railway, for three reasons that have nothing to do with taste: there is no record of what ran, so you cannot tell which of your four environments has which shape; nobody reviewed the SQL in a pull request, because there was no SQL; and a column you deleted from the schema file becomes a dropped column with data in it.
Pick one and never mix them on the same database. If you push to a database and later run migrate against it, the migrations table is empty, so Drizzle will try to apply migration 0000 to a database that already has all the tables, and the deploy dies on relation "users" already exists.
Running drizzle-kit migrate on every Railway deploy
Railway's pre-deploy command runs between building and deploying, in a separate container, with your service's environment variables and private network access. The docs are direct about failure: "If your command fails, it will not be retried and the deployment will not proceed." That is exactly the semantics you want for schema changes.
Set it in the service's Settings, under Deploy:
npm run db:migrate
Two things break this in a Docker build. drizzle-kit is usually a devDependency, so a multi-stage build that runs npm ci --omit=dev will not have it. And a build that only copies dist/ will not have the drizzle/ folder. Either copy both into the final image, or skip the CLI and use the programmatic migrator, which needs only drizzle-orm and pg at runtime:
// scripts/migrate.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await migrate(drizzle(pool), { migrationsFolder: "./drizzle" });
await pool.end();
Then the pre-deploy command is node dist/scripts/migrate.js, and you still need drizzle/ in the image.
Under the hood, migrate reads the journal, connects, and works out which migrations are missing from the migrations log table. The table is __drizzle_migrations in the drizzle schema by default, with columns id, hash, and created_at.
The skip decision does not use the hash. The migrator selects the single most recent row by created_at and applies every journal entry whose when timestamp is greater than it. All pending migrations run inside one transaction, so if the third one fails, the first two roll back with it and the deploy stops with nothing half applied.
Verify it. Deploy once, then open the Postgres service's Data tab or connect with psql and run:
select id, created_at from drizzle.__drizzle_migrations order by created_at;
You should see one row per entry in _journal.json. If the counts differ, stop and find out why before shipping anything else.
Set a Pre-deploy Timeout while you are in Settings. By default the command "runs until it exits", and a migration waiting on an ACCESS EXCLUSIVE lock behind a long-running query will sit there for as long as that query lasts.
The other common advice is to call migrate() at app startup. Don't. The same migrator code takes no advisory lock, so two instances booting at once can both read the same last row and both decide the same migration is pending. A migration that throws now crashes the process that was supposed to serve traffic, and Railway restarts it into the same failure. A failed pre-deploy leaves the previous deployment running; a failed startup migration takes the service down.
Drizzle's own Railway tutorial uses the startup call as its default and offers the pre-deploy command as the zero-downtime alternative. If you take that path, it tells you to remove the await migrate(db, { migrationsFolder: "./migrations" }) call from your entrypoint. Take that path.
Portreeve's API runs on this stack, Drizzle against Railway Postgres, with migrate in the pre-deploy step, for exactly this reason: a bad migration should cost a red deploy, not an outage on a service that is in the middle of answering requests.
The redeploy that skipped your migration
You commit a migration, hit Redeploy on the latest deployment, and the new column is not there.
Railway's Redeploy creates "a new deployment with the exact same code and build/deploy configuration" as the one you selected. Not your latest commit. The build is reused, so a migration committed after that image was built does not exist in the container the pre-deploy command runs from. Restart is the same story from the other direction: restarting a crashed deployment "restores the exact image containing the code & configuration of the original build."
How to tell: every deployment in the Railway dashboard shows the commit it was built from. Compare that SHA against git log -1 on your default branch. If they differ, the SQL is not in the image.
How to force a build from the repo: push a commit, which makes the GitHub trigger build the new SHA, or run railway up from the repo root, which deploys the current directory. Redeploy is for re-running the same code, and rollback is deliberately the same mechanism, since a rollback restores the previous Docker image.
This is also the reason to keep migrations backward-compatible with the previous image. A Railway rollback restores old code, not old schema, and there is no down migration waiting to meet it.
Rolling back a drizzle migration when there is no down file
Drizzle has no down migrations. The request has been open since October 2023, when @rafaell-lycan asked "Is there any plan for rollback migrations?", and on Hacker News @theogravity put it plainly: "There's no 'down' at all from what we saw."
There is drizzle-kit drop, an interactive picker that removes a migration's SQL file, snapshot, and journal entry from your working directory. It does not touch the database. It also no longer appears in the documented command list, and the v1 release candidate removes it outright along with _journal.json itself. It still ships in 0.31.x, which is what drizzle-kit@latest gives you today. Deleting those three files by hand does the same thing and works on either version.
Either way, using it on a migration that has already been applied somewhere is how you end up with a journal that no longer describes reality.
So the rules are short.
Local mistake, not yet pushed, not yet applied anywhere but your dev database. Drop the migration, fix the schema file, regenerate. Reset your local database while you are there so the snapshot and the database agree again.
Applied in production. Write a forward migration. If you added a column, the fix is a migration that drops it. If you dropped a column, the data is gone and a forward migration only gets you the empty column back.
Applied in production and it destroyed data. Restore from a Railway backup and accept the write loss between the backup and now. Railway backups are volume snapshots on a daily, weekly, or monthly schedule, and a restore mounts a new volume from the snapshot while leaving the old one in place, so check that you have a schedule enabled before you need one.
This is the only case where a restore beats a forward fix, and it is why destructive migrations should be split. Deploy the code that stops using the column, wait a release, then drop the column in a separate migration. Two deploys, and the middle one is a safe rollback point.
Adding an index to a live table
Drizzle's index builder supports .concurrently(), so this looks fine:
export const events = pgTable("events", { /* ... */ }, (t) => ({
createdAtIdx: index("events_created_at_idx").on(t.createdAt).concurrently(),
}));
generate will happily emit CREATE INDEX CONCURRENTLY. Then migrate fails, because the migrator wraps the run in a transaction and the PostgreSQL manual is unambiguous: "a regular CREATE INDEX command can be performed within a transaction block, but CREATE INDEX CONCURRENTLY cannot." That is issue #860, opened by @MatanYadaev in July 2023 and still open.
A --custom migration does not escape it. The transaction wraps the migration, not the statement, so the advice you will find about generating a custom migration with a --> statement-breakpoint does not solve this particular problem.
Build the index out of band, then let the migration no-op:
- Add the index to your schema file, without
.concurrently(), and runnpm run db:generate. The snapshot now knows about it. - Open the generated SQL and change
CREATE INDEXtoCREATE INDEX IF NOT EXISTS. This is a safe hand-edit because the end state still matches the snapshot, which is the only invariant that matters. - Before deploying, build it against production yourself:
psql "$DATABASE_URL" -c 'CREATE INDEX CONCURRENTLY "events_created_at_idx" ON "events" ("created_at")'
- Deploy. The pre-deploy migration runs, the index already exists, the statement does nothing, and the journal advances. Fresh databases and CI run the plain
CREATE INDEXon an empty table, which is instant.
If step 3 fails halfway, Postgres leaves behind an index that "will be ignored for querying purposes because it might be incomplete; however it will still consume update overhead." Find it and drop it:
select indexrelid::regclass from pg_index where indisvalid = false;
drop index concurrently "events_created_at_idx";
Then run the concurrent build again.
Merges, timestamps, and drizzle-kit check
Timestamps are the ordering, so merges need care. Two branches that each generate a migration produce two journal entries, and if the one merged second carries the earlier when, production applies the later one first and then never applies the earlier one at all.
Run npx drizzle-kit check in CI. The docs describe it as a way to "check consistency of your generated SQL migrations history" and call it useful when several people are altering the schema on different branches, which is the polite version of the failure above. When it fires, drop your migration locally, rebase, and regenerate on top of main.
One invariant covers the rest. The schema file and drizzle/meta/ change together, in the same commit, and only through drizzle-kit generate. Never hand-edit SQL in a way that changes the resulting schema, never delete a migration file that has been applied anywhere, and never run push against a database that migrate also touches.
The remainder is one line in Railway's Settings and not reaching for Redeploy when you meant to ship new code.
If the signup form you are shipping schema changes for is getting hammered at the same time, that is a different problem with a similar shape. Portreeve screens signups and checkouts and returns allow, review, or block from one API call, free for 1,000 events a month with no card: create an account.