Migrations¶
How schema changes reach the database, how to check they did, and what to do when one fails.
Companion to apps/server/drizzle/DEPLOY-NOTES.md, which lists the migrations that take heavy locks.
How it works¶
- Migrations are Drizzle SQL files in
apps/server/drizzle/NNNN_<tag>.sql(121 of them at 2026-09-17,0000…0120), tracked inapps/server/drizzle/meta/_journal.json. - The runner is
apps/server/src/db/migrate.ts(npm run db:migrate):drizzle-orm's migrator applies every journal entry not yet recorded in the database's__drizzle_migrationstable — all pending files inside one transaction (drizzle'smigrate()wraps the whole loop), then callsstorage.ensureBucket()so object storage exists. - In Production the
migratecompose service runs this once per deploy (restart: "no");apiandworkerdeclaredepends_on: migrate: service_completed_successfully, so the app never starts against a schema behind the code. A failed migration means the deploy stops with the previous containers still serving.
Writing one¶
- Change
apps/server/src/db/schema.ts. - Generate the SQL:
npm run db:generate --workspace apps/server(drizzle-kit) — or write it by hand when you need something drizzle-kit does not emit (partial indexes,CREATE INDEX CONCURRENTLY, data backfills). Hand-written files must be appended to_journal.jsonby hand with the nextidx, awhentimestamp and the filetag; the runner ignores files the journal does not list. - Never edit a migration that has been applied anywhere — its hash is recorded; write a new one.
- Run it locally against a scratch database (
npm run db:migrate), then run the full server suite alone (npm run test --workspace @bimzone/server; global setup drops and recreates the test database, so nothing else may run against it at the same time). - If the migration rewrites or index-builds a large table, add a section to
drizzle/DEPLOY-NOTES.mdsaying what locks it takes and how long — the runner cannot runCONCURRENTLYinside its transaction, so such migrations need a maintenance window or an out-of-band apply (create the index concurrently by hand, then record the migration as applied).
Verifying in Production¶
- Dokploy deployment log:
Migrations applied.from themigrateservice, thenapistarts. - Or query:
SELECT id, hash, created_at FROM drizzle.__drizzle_migrations ORDER BY id DESC LIMIT 3;— the newest tag must be the one you shipped. - The API's own behaviour: a route that needs the new column answers 200, not a Postgres
42703.
When a migration fails¶
- Read the
migratecontainer log — the runner printsMigration failed:and the Postgres error. The oldapi/workerare still running the previous code; users are not down. - Typical causes: a hand-written file with a syntax error; a constraint that existing rows violate; a lock timeout on a populated table (see DEPLOY-NOTES).
- Fix forward: write a corrected migration and push. Because every pending file ran in one transaction, a failure rolled all of them back — the database is exactly as it was before the deploy, and none of the pending files is recorded as applied. Editing the failed file is acceptable only if it is unreleased anywhere else; otherwise add a new file.
- Data-destroying mistakes are the case for Backups & restore › PITR: restore to just before the deploy, then fix forward.
Rolling back code without rolling back schema¶
Migrations here are additive by convention (add column, add table, add index). Rolling the application back one commit after a successful migration is therefore safe; rolling the schema back is not something the runner does — if a migration must be undone, write the inverse as a new migration.
Local development¶
DATABASE_URLinapps/server/.env;npm run db:migratefrom the repo root.- The test suite creates its own database from the same migrations on every run — a migration that breaks tests breaks the build before it can reach Production.