Yesterday morning I published a post about a one-line deployment policy:
"build": "drizzle-kit migrate && next build"
Timestamp on it: 10:19 UTC. Timestamp on the commit that removed it: 13:20 UTC the same day. One line changed:
- "build": "drizzle-kit migrate && next build",
+ "build": "next build",
Writing the argument down is what killed it. That is the only reason this post exists, because the first one is still up and is still an accurate description of what the repository said at the time.
What the combined command was actually buying
The case for it was not laziness. It was ordering. With the migrator in front of the compiler it is structurally impossible to deploy application code before the schema that code expects, because the two happen in one process in a fixed order, and the && means a failed migration never produces a build. Nobody has to remember to run anything. There is no runbook step to skip at 11pm.
The schema history also made it look safe. Fourteen migration files, 174 lines of SQL between them, and thirteen of the fourteen are additive: new table, new column, new index. The risk per migration was genuinely small.
Four problems, in increasing order of how much they bothered me
The build machine held production database credentials. drizzle.config.ts reads them straight out of the environment:
config({ path: '.env.local' })
config({ path: '.env' })
export default defineConfig({
schema: "./utils/db/schema.ts",
out: "./utils/db/migrations",
dialect: "postgresql",
dbCredentials: { url: process.env.DATABASE_URL! },
})
On a deployment platform neither .env file exists, so that ! resolves to whatever the project has configured, which is the real database. A build is the least supervised thing in the whole pipeline, it runs on a machine I do not control, triggered by a push, and I had given it write access to the schema.
A schema failure could stop content from shipping. This is the one that actually changed my mind. Seventy-two URLs are in our sitemap, and seventy of them are build artifacts:
curl -sI https://pub-trivia.app/features | grep -i x-nextjs-prerender
# x-nextjs-prerender: 1
Only / and /pricing are computed per request, for the currency reason those two pages have been documented to death. The other seventy, every guide, every comparison, every free question set, are HTML produced at build time. And none of them read Postgres. Two page.tsx files in the whole project import the database client, and both of them are behind the login.
So with the old command, one bad SQL statement in a migration meant a typo fix on the features hub did not ship. Seventy pages whose content lives in a TypeScript module, held hostage by a column rename. That coupling is not a tradeoff, it is just wrong.
Rollback only ever rolled back half of it. Redeploying the previous build restores the old code. It does not un-apply the migration that the newer build ran, because drizzle-kit migrate moves forward only. So the exact scenario the combined command was supposed to make impossible, old code running against new schema, was the thing a rollback produced, every time, silently. The pipeline guaranteed ordering on the way up and guaranteed nothing on the way down.
Two builds at once are two migrators at once. Push twice in a minute and both builds race to apply the same pending file. Drizzle's journal makes that mostly survivable, "mostly" being the problem.
What replaced it
Nothing. The scripts were already there:
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
Migration is now a thing a person does, on purpose, before the deploy that needs it. Which is already the rule I work to by hand: generate the SQL as its own step, read it, apply it as its own step, and never let a tool decide on my behalf that now is a good time to alter a table.
The failure mode I chose instead
This is strictly worse in one dimension and I want to be straight about it. Code can now reach production expecting a column that is not there. The protection against that used to be structural and is now procedural, which means it is only as good as the ordering a human remembers at the time.
What makes it an acceptable trade is the shape of the blast radius. Before: the schema and the site shipped together, so a schema problem took the whole deploy, content included. After: a schema problem is a 500 on one dashboard route while seventy pages of content, the sign-up flow and the pricing page carry on being served from the edge exactly as they were. The site stops being able to fail for reasons that have nothing to do with it.
The right fix for the gap is not to put the migrator back in the build. It is to make the migrations themselves safe to run out of order with the deploy, which is what additive-only already mostly gets you: add the column, ship the code that writes it, ship the code that requires it, and never do all three in one move.
Three hours between publishing a defence of something and deleting it is not a great look. It is a better look than the alternative, which was to keep it because I had already told people it was deliberate.
Top comments (0)