czay.dev
Writing

Drizzle ORM schema and migration management in production

Generate, push, or migrate? Running migrations on container startup, fixing connection pool leaks in Next dev, and preventing next build from breaking due to database requests—from a real-world panel with 17 migrations.

Furkan ÖzaySeptember 2, 2026 · 7 min read

Drizzle's tutorials usually end with defining a schema and running a query. But in production, the real questions begin: when do you apply migrations, what happens when next build can't access the database, and why does your development server hit connection limits after a while?

This post is based on the student.czay.dev panel—a setup with a 1,000-line schema, 17 migrations, running on my own server with Docker.

Three commands, three different jobs

This is the most confusing part of Drizzle Kit.

CommandWhat it doesWhere it is used
drizzle-kit generateGenerates a SQL file from the diff between the schema and the databaseOn every schema change
drizzle-kit migrateSequentially applies the generated SQL filesLocally and in production
drizzle-kit pushPushes the schema directly to the database without generating filesPrototypes only

Caution: don't use push in production

push is fast and tempting: change schema, push, done. But it leaves no records behind. There's no history of applied changes, no way to roll back, and no way to replicate the exact steps in another environment.

Use generate + migrate in any project with a production database. Save push for one-man prototypes or throwaway databases.

Giving migrations meaningful names also pays off later. The folder in this panel looks like this:

plaintext
drizzle/
  0000_taban.sql
  0001_dogum_tarihi.sql
  0002_ders_serisi.sql
  0003_grup_panosu.sql
  0004_saklama_ve_silme.sql
  …
  0015_sinif_odulleri.sql
  0016_sosyal_profiller.sql

Six months from now, the answer to "when was this field added?" is right there in the folder.

When should you apply migrations?

Answer: not during build, but on container startup.

Explanation: Why not during build?

When building the image, you don't have access to the database, nor should you—the same image must be deployable to another database. Besides, your deployment tool's restarts, scaling, and rollbacks all use the same image; you can't bake the schema state into the image.

A migration is the state of the database, not the code. The code version lives in the image, while the schema state lives in the database.

The entrypoint script runs the migrations before starting the server:

Bash
#!/bin/sh
set -e
 
echo "[entrypoint] applying schema migrations…"
node scripts/migrate.cjs
 
echo "[entrypoint] starting server…"
exec node apps/student/server.js

If a migration fails, the container won't start at all thanks to set -e. This is intentional: a version serving requests with a broken schema is worse than a version that won't start—errors would only crop up silently on the first user request.

Resolve the migration folder relative to the script

A small detail, but one that hurts in production:

TypeScript
import path from "node:path";
 
// Relative to the script's location, NOT the working directory
const gocKlasoru = path.join(__dirname, "..", "drizzle");

In the source, the script lives under db/, while in the container, the bundled version sits under scripts/. In both cases, the parent's drizzle/ is correct; but if you rely on the working directory, it won't find the folder inside the container.

Two runners, one record

Locally, drizzle-kit migrate runs, while in production, my bundled script takes over. Since both use the same drizzle.__drizzle_migrations table, the tracking is shared no matter which one runs—a migration is never applied twice. If you write your own runner, don't change this table.

next build wants a database

This is the classic trap of combining Next.js with an ORM, and it's hard to diagnose.

The problem is that Next.js evaluates modules during the build phase. If you initialize the database client at the module level, that setup executes during build time, and if DB_POSTGRES_URL is missing, next build breaks. Yet, the build process has no actual need for the database.

In my case, the trigger was better-auth touching the adapter at initialization time after a certain version. The build was running on a CI server without database access, and it kept blowing up.

Example: Solution: Defer connection until the first query

TypeScript
function connect(): Database {
	if (globalForDb.__studentDb) return globalForDb.__studentDb;
 
	const derlemede = process.env.NEXT_PHASE === "phase-production-build";
	const url =
		process.env.DB_POSTGRES_URL ??
		(derlemede ? "postgresql://[email protected]:5432/derleme" : undefined);
 
	if (!url) {
		throw new Error("DB_POSTGRES_URL is not defined.");
	}
 
	const sql = globalForDb.__studentSql ?? postgres(url, {
		max: 10,
		connect_timeout: 10,
	});
 
	const instance = drizzle(sql, { schema });
	globalForDb.__studentSql = sql;
	globalForDb.__studentDb = instance;
	return instance;
}
 
/** Connection is established on the first query, not during module evaluation. */
export const db = new Proxy({} as Database, {
	get: (_target, prop) => connect()[prop as keyof Database],
});

Two things are at play here:

  • The placeholder address only kicks in during the build phase (NEXT_PHASE). At runtime, the environment variable is still mandatory, and the error message remains the same.
  • postgres.js opens the socket on the first query, not during initialization. This means no connection attempt is ever made with the placeholder address; not a single packet is sent during the entire build.

And that's what the Proxy is for: connect() is not executed until the db object is actually touched.

Connection pool leak

There are two separate leaks, and both progress silently.

Warning: 1. Module reloading in development

The Next.js development server reloads modules on every code change. Each reload opens a new postgres() pool, and the old one is never closed. After working for half an hour, you hit Postgres's default limit of 100 connections and get a "too many clients" error.

The solution is to keep the pool on globalThis: even if the module reloads, the object persists.

Caution: 2. Proxy in production

The second one is subtler, and I caught it in production. I was caching the pool only in development—which is the common advice. However, the proxy above calls connect() on every property access. This means every call to db.select, db.insert, or db.query in production was setting up a new pool.

Since the pools were never closed, the connection count grew with every single request. The fix: cache it in production as well.

The pool size should also be chosen deliberately. In this panel, I use max: 10: since Postgres is on the same machine, establishing connections is cheap. 10 sockets are more than enough for this level of concurrency, and it doesn't strain Postgres's default limits.

Configuration

TypeScript
// drizzle.config.ts
export default defineConfig({
	schema: "./db/schema.ts",
	out: "./drizzle",
	dialect: "postgresql",
	dbCredentials: { url: databaseUrl },
	strict: true,
	verbose: true,
});

strict: true asks for confirmation before generating a destructive action—it prevents you from accidentally generating a migration that deletes a column. verbose: true prints the generated SQL. Keep both enabled; reading the migration file before applying it is the cheapest security measure in this game.

Summary

Summary: Production checklist

  • Use generate + migrate; save push for prototyping
  • Give migration files meaningful names—you will thank yourself six months from now
  • Run migrations on container startup, not during build
  • Resolve the migration folder using __dirname, not the working directory
  • Share the __drizzle_migrations table even if you write your own runner
  • Defer connections until the first query; next build shouldn't require a database
  • Keep the pool on globalThis—both in development and production
  • Read the SQL generated with strict: true before applying it

Related post: Deploying Next.js on your own server