Escritos

Drizzle ORM: gestión de esquema y migraciones en producción

¿Generate, push o migrate? Ejecutar migraciones al arrancar el contenedor, arreglar fugas del pool de conexiones en el modo dev de Next y evitar que next build falle por consultas a la base de datos, a partir de un panel real con 17 migraciones.

Furkan Özay2 de septiembre de 2026 · 7 min de lectura
czCZAY.DEV // EDITORIALES
MODERN WEB & REACT

#Araçlar · 7 MIN DE LECTURA

FURKAN ÖZAYVOL. 2026 // #168F

Los tutoriales de Drizzle suelen terminar al definir un esquema y ejecutar una consulta. Pero en producción empiezan las preguntas de verdad: ¿cuándo aplicas las migraciones?, ¿qué pasa cuando next build no puede acceder a la base de datos? y ¿por qué tu servidor de desarrollo alcanza el límite de conexiones al cabo de un rato?

Este artículo se basa en el panel student.czay.dev: una configuración con un esquema de 1000 líneas y 17 migraciones, que corre en mi propio servidor con Docker.

Tres comandos, tres trabajos distintos

Esta es la parte más confusa de Drizzle Kit.

ComandoQué haceDónde se usa
drizzle-kit generateGenera un archivo SQL a partir de la diferencia entre el esquema y la base de datosEn cada cambio de esquema
drizzle-kit migrateAplica en orden los archivos SQL generadosEn local y en producción
drizzle-kit pushEnvía el esquema directamente a la base de datos sin generar archivosSolo prototipos

Precaución: no uses push en producción

push es rápido y tentador: cambias el esquema, haces push y listo. Pero no deja ningún registro. No hay historial de los cambios aplicados, no hay forma de revertir y no hay manera de reproducir los mismos pasos en otro entorno.

Usa generate + migrate en cualquier proyecto con una base de datos de producción. Reserva push para prototipos personales o bases de datos desechables.

Ponerle nombres con sentido a las migraciones también compensa más adelante. La carpeta de este panel tiene este aspecto:

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

Dentro de seis meses, la respuesta a "¿cuándo se añadió este campo?" estará ahí mismo, en la carpeta.

¿Cuándo se deben aplicar las migraciones?

Respuesta: no durante la compilación, sino al arrancar el contenedor.

Explicación: ¿Por qué no durante la compilación?

Al construir la imagen no tienes acceso a la base de datos, ni deberías tenerlo: la misma imagen debe poder desplegarse sobre otra base de datos. Además, los reinicios, el escalado y los rollbacks de tu herramienta de despliegue usan la misma imagen; no puedes incrustar el estado del esquema en la imagen.

Una migración es el estado de la base de datos, no del código. La versión del código vive en la imagen, mientras que el estado del esquema vive en la base de datos.

El script de entrypoint ejecuta las migraciones antes de arrancar el servidor:

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

Si una migración falla, el contenedor no arranca en absoluto gracias a set -e. Es intencional: una versión que atiende solicitudes con un esquema roto es peor que una versión que no arranca, porque los errores solo aparecerían en silencio con la primera solicitud de un usuario.

Resuelve la carpeta de migraciones relativa al script

Un detalle pequeño, pero que duele en producción:

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

En el código fuente, el script vive bajo db/, mientras que en el contenedor la versión empaquetada está bajo scripts/. En ambos casos, el drizzle/ del directorio padre es el correcto; pero si dependes del directorio de trabajo, no encontrará la carpeta dentro del contenedor.

Dos ejecutores, un solo registro

En local se ejecuta drizzle-kit migrate, mientras que en producción se encarga mi script empaquetado. Como ambos usan la misma tabla drizzle.__drizzle_migrations, el seguimiento es compartido sin importar cuál se ejecute: una migración nunca se aplica dos veces. Si escribes tu propio ejecutor, no cambies esta tabla.

next build quiere una base de datos

Esta es la trampa clásica de combinar Next.js con un ORM, y es difícil de diagnosticar.

El problema es que Next.js evalúa los módulos durante la fase de compilación. Si inicializas el cliente de la base de datos a nivel de módulo, esa inicialización se ejecuta durante la compilación y, si falta DB_POSTGRES_URL, next build falla. Y sin embargo, el proceso de compilación no necesita realmente la base de datos.

En mi caso, el detonante fue que better-auth, a partir de cierta versión, tocaba el adaptador en el momento de la inicialización. La compilación corría en un servidor de CI sin acceso a la base de datos y no dejaba de reventar.

Ejemplo: Solución: aplaza la conexión hasta la primera consulta

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],
});

Aquí intervienen dos cosas:

  • La dirección de relleno solo se activa durante la fase de compilación (NEXT_PHASE). En tiempo de ejecución la variable de entorno sigue siendo obligatoria y el mensaje de error no cambia.
  • postgres.js abre el socket con la primera consulta, no durante la inicialización. Esto significa que nunca se intenta una conexión con la dirección de relleno; no se envía ni un solo paquete durante toda la compilación.

Para eso sirve el Proxy: connect() no se ejecuta hasta que realmente se toca el objeto db.

Fuga del pool de conexiones

Hay dos fugas distintas y ambas avanzan en silencio.

Advertencia: 1. Recarga de módulos en desarrollo

El servidor de desarrollo de Next.js recarga los módulos con cada cambio de código. Cada recarga abre un nuevo pool de postgres() y el anterior nunca se cierra. Después de trabajar media hora, alcanzas el límite por defecto de Postgres de 100 conexiones y aparece el error "too many clients".

La solución es mantener el pool en globalThis: aunque el módulo se recargue, el objeto persiste.

Precaución: 2. El Proxy en producción

La segunda es más sutil y la detecté en producción. Estaba cacheando el pool solo en desarrollo, que es el consejo habitual. Sin embargo, el proxy de arriba llama a connect() en cada acceso a una propiedad. Esto significa que cada llamada a db.select, db.insert o db.query en producción creaba un pool nuevo.

Como los pools nunca se cerraban, el número de conexiones crecía con cada solicitud. La solución: cachéalo también en producción.

El tamaño del pool también debe elegirse con criterio. En este panel uso max: 10: como Postgres está en la misma máquina, establecer conexiones es barato. 10 sockets son más que suficientes para este nivel de concurrencia y no fuerzan los límites por defecto de Postgres.

Configuración

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

strict: true pide confirmación antes de generar una acción destructiva: evita que generes por accidente una migración que elimina una columna. verbose: true imprime el SQL generado. Mantén ambos activados; leer el archivo de migración antes de aplicarlo es la medida de seguridad más barata de todo este juego.

Resumen

Resumen: Lista de verificación para producción

  • Usa generate + migrate; reserva push para prototipos
  • Dale nombres con sentido a los archivos de migración: dentro de seis meses te lo agradecerás
  • Ejecuta las migraciones al arrancar el contenedor, no durante la compilación
  • Resuelve la carpeta de migraciones con __dirname, no con el directorio de trabajo
  • Comparte la tabla __drizzle_migrations aunque escribas tu propio ejecutor
  • Aplaza las conexiones hasta la primera consulta; next build no debería requerir una base de datos
  • Mantén el pool en globalThis, tanto en desarrollo como en producción
  • Lee el SQL generado con strict: true antes de aplicarlo

Artículo relacionado: Desplegar Next.js en tu propio servidor

Hoja de ruta y herramienta interactivas

Sigue leyendo