Nuxt+Prisma+Docker: Sincronización automática de bases de datos (PostgreSQL)
En la fase de construcción de la imagen Docker no se ejecutan las migraciones de base de datos; la sincronización tiene lugar después de que el contenedor se inicia y el proceso de la aplicación arranca.
Renderizando...
Este artículo presenta una solución para sincronizar bases de datos en Docker. El entorno de desarrollo básico de este artículo es Nuxt4 + Prisma + Docker. La sincronización ocurre después del inicio del contenedor y el levantamiento del proceso de la aplicación. La lógica central son los scripts de sincronización de la base de datos y los scripts que se ejecutan al iniciar el proyecto. Nuxt proporciona un hook de desarrollo de plugins de tipo `Server Start`, por lo que es muy fácil de implementar. Para otros entornos de desarrollo, consulte la implementación por su cuenta.
**No se ejecutan** migraciones de base de datos durante la fase de construcción de la imagen de Docker; la sincronización ocurre **después del inicio del contenedor y el levantamiento del proceso de la aplicación**, donde un plugin de inicio de Nitro invoca un ejecutor de migración personalizado, lee `prisma/migrations/*/migration.sql` dentro de la imagen y lo escribe en la tabla `_prisma_migrations` compatible con Prisma.
Los puntos clave para la reproducción son tres:
1. **Incluir** `prisma/migrations` (y los artefactos de Prisma Client en tiempo de ejecución `prisma/generated`) **en la imagen**.
2. **Implementar** `server/lib/db-migrations.ts` (código completo a continuación).
3. **Invocar** `ensureDatabaseMigrations()` **en la etapa más temprana del inicio de la aplicación** (ejemplo de plugin a continuación).
El contenido del directorio `prisma/migrations` es generado automáticamente por comandos como `prisma migrate dev`. El esquema de cada proyecto es diferente, por lo que este artículo no profundiza en el SQL de migración en sí.
## Flujo general
```mermaid
sequenceDiagram
participant Docker as Construcción Docker
participant CMD as node server/index.mjs
participant Plugin as Plugin de inicio de Nitro
participant Mig as db-migrations.ts
participant PG as PostgreSQL
Docker->>Docker: pnpm build (incluye prisma generate)
Docker->>Docker: COPY .output + prisma/migrations + prisma/generated
Note over Docker: No se conecta a la base de datos ni se migra durante la construcción
CMD->>Plugin: Inicio del proceso
Plugin->>Mig: ensureDatabaseMigrations()
Mig->>PG: Si la base de datos no existe, CREATE DATABASE
Mig->>PG: Bloqueo consultivo + lectura/escritura de _prisma_migrations
Mig->>PG: Ejecutar migration.sql no aplicados
Plugin->>CMD: Continuar con datos semilla y otras inicializaciones después de la migración
```
En comparación con "ejecutar `npx prisma migrate deploy` en `entrypoint.sh`", el **CMD del Dockerfile de producción actual inicia directamente Node**, sin depender de la instalación de Prisma CLI dentro de la imagen; la lógica de migración se completa completamente dentro de la aplicación utilizando el controlador `pg`.
El repositorio aún conserva `docker/entrypoint.sh` (método oficial de Prisma CLI), pero **no está vinculado al ENTRYPOINT/CMD del Dockerfile actual**, solo como referencia opcional, ver al final.
---
## Variables de entorno
| Variable | Obligatoria | Descripción |
|------|------|------|
| `DATABASE_URL` | Sí | Cadena de conexión de la base de datos de destino, debe incluir el nombre de la base de datos; `?schema=public` especifica el esquema de PostgreSQL |
| `DATABASE_AUTO_MIGRATE` | No | Si se establece en `false`, se omite la migración automática |
| `DATABASE_BOOTSTRAP_URL` | No | Conexión de administración utilizada para `CREATE DATABASE`, por defecto cambia el nombre de la base de datos de `DATABASE_URL` a `postgres` |
| `DATABASE_SCHEMA` | No | Nombre del esquema cuando el parámetro `schema` no está incluido en la URL, por defecto `public` |
Ejemplo de Compose (solo muestra la parte relacionada con la base de datos):
```yaml
environment:
DATABASE_URL: "postgresql://postgres:contraseña@host:5432/nombre_bd?schema=public"
# DATABASE_AUTO_MIGRATE: "false" # Se puede desactivar cuando la migración es ejecutada por operaciones externas
# DATABASE_BOOTSTRAP_URL: "postgresql://postgres:contraseña@host:5432/postgres"
```
---
## Imagen de Docker: qué hacer durante la construcción
Durante la fase de construcción, solo se empaqueta la aplicación y se copian los archivos de migración, **no se conecta a la base de datos**.
```dockerfile
FROM node:22.21.1-alpine3.22 AS builder
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22.21.1-alpine3.22 AS runner
WORKDIR /app
COPY --from=builder /app/.output ./
COPY --from=builder /app/prisma/generated ./prisma/generated
COPY --from=builder /app/prisma/migrations ./prisma/migrations
ENV DATABASE_URL="postgresql://user:pass@host:5432/dbname?schema=public"
EXPOSE 9090
CMD ["node", "/app/server/index.mjs"]
```
Notas:
- Antes de `pnpm build`, `prisma generate` debe haberse ejecutado en la máquina de desarrollo/CI para que `prisma/generated` exista y se copie en la imagen.
- El directorio de trabajo en tiempo de ejecución es `/app`. El ejecutor de migración lee SQL de `process.cwd()/prisma/migrations`, lo que coincide con la ruta de COPY anterior.
- La imagen final **no necesita** instalar el paquete `prisma` CLI.
---
## Plugin de inicio: ejecutar migraciones antes de la inicialización del negocio
Las migraciones deben completarse antes de cualquier consulta de Prisma. Se `await` durante el inicio a través de un plugin de Nitro:
```typescript
import { ensureDatabaseMigrations } from "~~/server/lib/db-migrations";
async function runStartupInitialization() {
await ensureDatabaseMigrations();
}
export default defineNitroPlugin(async () => {
await runStartupInitialization();
});
```
Las llamadas posteriores como `prisma.systemConfig.create` en el mismo archivo de plugin pertenecen a la **siembra de datos**, no a la sincronización de la estructura; para la reproducción, solo se necesita mantener la llamada a `ensureDatabaseMigrations()`, la lógica de siembra se puede reducir según el proyecto.
---
## Núcleo: Ejecutor de migración personalizado (código completo)
Convención de ruta: `server/lib/db-migrations.ts`.
Resumen del comportamiento:
1. Lee `prisma/migrations/<name>/migration.sql` ordenado por nombre de directorio.
2. Si la base de datos de destino no existe (PostgreSQL `3D000`), ejecuta `CREATE DATABASE` usando la conexión de bootstrap.
3. Crea/usa el esquema, obtiene `pg_advisory_lock` para evitar migraciones concurrentes de múltiples instancias.
4. Mantiene la tabla `_prisma_migrations` (campos consistentes con Prisma), el checksum es el SHA-256 del SQL.
5. **Línea base (Baseline)**: Si ya existen tablas de negocio pero no hay historial de migración, si la validación `validateExistingSchemaIsLatest` pasa, solo se escriben los registros de migración y no se ejecuta el SQL nuevamente (ver sección de adaptación a continuación).
6. Ejecuta el SQL en una transacción para las migraciones restantes no aplicadas y las registra.
Dependencias: `pg` (ya en `dependencies` de `package.json`).
```typescript
import { createHash, randomUUID } from "node:crypto";
import { access, readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { Client } from "pg";
const MIGRATIONS_DIR = path.resolve(process.cwd(), "prisma", "migrations");
const MIGRATIONS_TABLE = "_prisma_migrations";
const LOCK_KEY_1 = 20260328;
const LOCK_KEY_2 = 1;
type MigrationFile = {
name: string;
sql: string;
checksum: string;
};
type DatabaseTarget = {
databaseUrl: string;
adminUrl: string;
databaseName: string;
schemaName: string;
};
// Lista de nombres de tablas, solo como ejemplo
const EXPECTED_TABLES = [
"users",
"settings",
];
let migrationTask: Promise<void> | null = null;
function getChecksum(sql: string) {
return createHash("sha256").update(sql).digest("hex");
}
function quoteIdentifier(value: string) {
return `"${value.replaceAll(`"`, `""`)}"`;
}
function getDatabaseTarget(): DatabaseTarget {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is required before applying migrations.");
}
const parsed = new URL(databaseUrl);
const databaseName = decodeURIComponent(parsed.pathname.replace(/^\//, ""));
if (!databaseName) {
throw new Error("DATABASE_URL must include a database name.");
}
const schemaName =
parsed.searchParams.get("schema") ||
process.env.DATABASE_SCHEMA ||
"public";
const adminUrl =
process.env.DATABASE_BOOTSTRAP_URL ||
(() => {
const bootstrap = new URL(databaseUrl);
bootstrap.pathname = "/postgres";
return bootstrap.toString();
})();
return {
databaseUrl,
adminUrl,
databaseName,
schemaName,
};
}
async function readMigrationFiles(): Promise<MigrationFile[]> {
try {
await access(MIGRATIONS_DIR);
} catch {
return [];
}
const entries = await readdir(MIGRATIONS_DIR, { withFileTypes: true });
const migrations = await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.sort((a, b) => a.name.localeCompare(b.name))
.map(async (entry) => {
const sql = await readFile(
path.join(MIGRATIONS_DIR, entry.name, "migration.sql"),
"utf8"
);
return {
name: entry.name,
sql,
checksum: getChecksum(sql),
};
})
);
return migrations;
}
async function ensureMigrationsTable(client: Client) {
await client.query(`
CREATE TABLE IF NOT EXISTS "${MIGRATIONS_TABLE}" (
"id" TEXT PRIMARY KEY,
"checksum" TEXT NOT NULL,
"finished_at" TIMESTAMPTZ,
"migration_name" TEXT NOT NULL UNIQUE,
"logs" TEXT,
"rolled_back_at" TIMESTAMPTZ,
"started_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"applied_steps_count" INTEGER NOT NULL DEFAULT 0
);
`);
}
async function ensureSchema(client: Client, schemaName: string) {
await client.query(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)}`);
await client.query(`SET search_path TO ${quoteIdentifier(schemaName)}`);
}
async function loadAppliedMigrations(client: Client) {
const result = await client.query<{
migration_name: string;
checksum: string;
}>(
`SELECT "migration_name", "checksum"
FROM "${MIGRATIONS_TABLE}"
WHERE "rolled_back_at" IS NULL`
);
return new Map(
result.rows.map((row) => [row.migration_name, row.checksum] as const)
);
}
async function hasUserTables(client: Client) {
const result = await client.query<{ exists: boolean }>(
`
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = ANY($1::text[])
) AS "exists"
`,
[EXPECTED_TABLES]
);
return Boolean(result.rows[0]?.exists);
}
async function validateExistingSchemaIsLatest(client: Client) {
const tableCountResult = await client.query<{ count: string }>(
`
SELECT COUNT(*)::text AS "count"
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = ANY($1::text[])
`,
[EXPECTED_TABLES]
);
const existingTableCount = Number(tableCountResult.rows[0]?.count ?? 0);
if (existingTableCount !== EXPECTED_TABLES.length) {
return false;
}
const metaColumnResult = await client.query<{ exists: boolean }>(
`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'user_chat_messages'
AND column_name = 'meta'
) AS "exists"
`
);
const removedColumnsResult = await client.query<{
fund_account_type_exists: boolean;
flow_pay_type_exists: boolean;
fixed_flow_pay_type_exists: boolean;
}>(`
SELECT
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'user_fund_accounts'
AND column_name = 'accountType'
) AS "fund_account_type_exists",
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'user_flows'
AND column_name = 'payType'
) AS "flow_pay_type_exists",
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'user_fixed_flows'
AND column_name = 'payType'
) AS "fixed_flow_pay_type_exists"
`);
const removed = removedColumnsResult.rows[0];
return (
Boolean(metaColumnResult.rows[0]?.exists) &&
!removed?.fund_account_type_exists &&
!removed?.flow_pay_type_exists &&
!removed?.fixed_flow_pay_type_exists
);
}
async function ensureDatabaseExists(target: DatabaseTarget) {
const probeClient = new Client({
connectionString: target.databaseUrl,
});
try {
await probeClient.connect();
await probeClient.end();
return;
} catch (error: any) {
try {
await probeClient.end();
} catch {
// Ignorar errores de cierre después de una conexión fallida.
}
if (error?.code !== "3D000") {
throw error;
}
}
const adminClient = new Client({
connectionString: target.adminUrl,
});
await adminClient.connect();
try {
const existsResult = await adminClient.query<{ exists: boolean }>(
"SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1) AS exists",
[target.databaseName]
);
if (!existsResult.rows[0]?.exists) {
await adminClient.query(
`CREATE DATABASE ${quoteIdentifier(target.databaseName)}`
);
console.log(`[db:migrate] created database ${target.databaseName}`);
}
} finally {
await adminClient.end();
}
}
async function baselineMigrations(
client: Client,
migrations: MigrationFile[],
applied: Map<string, string>
) {
if (applied.size > 0) {
return;
}
const hasTables = await hasUserTables(client);
if (!hasTables) {
return;
}
const isLatestSchema = await validateExistingSchemaIsLatest(client);
if (!isLatestSchema) {
throw new Error(
"Existing database schema detected without _prisma_migrations history, and the schema does not match the current expected version. Refusing automatic baseline to avoid duplicate execution or destructive drift."
);
}
await client.query("BEGIN");
try {
for (const migration of migrations) {
await client.query(
`INSERT INTO "${MIGRATIONS_TABLE}" ("id", "checksum", "finished_at", "migration_name", "started_at", "applied_steps_count") VALUES ($1, $2, NOW(), $3, NOW(), 1)`,
[randomUUID(), migration.checksum, migration.name]
);
applied.set(migration.name, migration.checksum);
}
await client.query("COMMIT");
console.log("[db:migrate] baselined existing schema");
} catch (error) {
await client.query("ROLLBACK");
throw error;
}
}
async function applyPendingMigrations() {
const target = getDatabaseTarget();
const migrations = await readMigrationFiles();
if (migrations.length < 1) {
return;
}
await ensureDatabaseExists(target);
const client = new Client({
connectionString: target.databaseUrl,
});
await client.connect();
try {
await ensureSchema(client, target.schemaName);
await client.query("SELECT pg_advisory_lock($1, $2)", [
LOCK_KEY_1,
LOCK_KEY_2,
]);
await ensureMigrationsTable(client);
const applied = await loadAppliedMigrations(client);
await baselineMigrations(client, migrations, applied);
for (const migration of migrations) {
const appliedChecksum = applied.get(migration.name);
if (appliedChecksum) {
if (appliedChecksum !== migration.checksum) {
throw new Error(
`Migration checksum mismatch: ${migration.name}. Existing databases cannot safely apply a modified migration file.`
);
}
continue;
}
await client.query("BEGIN");
try {
await client.query(migration.sql);
await client.query(
`INSERT INTO "${MIGRATIONS_TABLE}" ("id", "checksum", "finished_at", "migration_name", "started_at", "applied_steps_count") VALUES ($1, $2, NOW(), $3, NOW(), 1)`,
[randomUUID(), migration.checksum, migration.name]
);
await client.query("COMMIT");
console.log(`[db:migrate] applied ${migration.name}`);
} catch (error) {
await client.query("ROLLBACK");
throw error;
}
}
} finally {
try {
await client.query("SELECT pg_advisory_unlock($1, $2)", [
LOCK_KEY_1,
LOCK_KEY_2,
]);
} catch {
// Ignorar errores de desbloqueo durante el apagado/manejo de errores.
}
await client.end();
}
}
export async function ensureDatabaseMigrations() {
if (process.env.DATABASE_AUTO_MIGRATE === "false") {
return;
}
migrationTask ??= applyPendingMigrations();
return migrationTask;
}
```
## Solución opcional: Llamar a Prisma CLI en entrypoint
Utiliza la capacidad `npx prisma migrate deploy` proporcionada por Prisma CLI para lograr la sincronización de la base de datos, pero esta solución requiere instalar Prisma CLI en el contenedor, lo que aumentaría considerablemente el tamaño de la imagen, por lo que no se recomienda.
## Operaciones y resolución de problemas
| Fenómeno | Posible causa |
|------|----------|
| Error de inicio `DATABASE_URL is required` | Variable de entorno no inyectada |
| `Migration checksum mismatch` | El archivo de migración aplicado se modificó, es necesario crear una nueva migración en lugar de modificar el SQL histórico |
| La línea base (baseline) se niega a ejecutarse | La estructura de la base de datos antigua no cumple con `validateExistingSchemaIsLatest`, es necesario alinearla manualmente o procesarla con `prisma migrate resolve` |
| No se encuentran migraciones dentro del contenedor | No se copió `prisma/migrations` o `cwd` no es `/app` |
| Múltiples réplicas se inician simultáneamente | El bloqueo consultivo serializará las migraciones; aún se recomienda iniciar una sola instancia primero durante la publicación o establecer `DATABASE_AUTO_MIGRATE=false` para que un Job lo ejecute |
Prefijo del registro: `[db:migrate]`, por ejemplo `created database`, `baselined existing schema`, `applied <migration_name>`.Comentario
Inicia sesión para ver y publicar comentarios
Ir a iniciar sesión