feat(db): wire migration runner into the deploy pipeline

Makes schema changes deploy automatically: edit schema -> db:generate ->
commit -> push -> Dokploy redeploys -> migrations apply on container start.
No more Dokploy database terminal.

Components:
- src/db/migrate.ts: standalone migrator (single short-lived connection,
  fails loud on error so a bad migration crashes the container instead of
  letting the app serve a half-migrated schema)
- scripts/build-migrator.mjs: esbuild bundles migrate.ts -> dist/migrate.mjs
  with drizzle-orm + postgres inlined. Needed because Next.js standalone
  output keeps neither as a separate node_modules package.
- Dockerfile: builder runs db:build-migrator; runner copies migrate.mjs +
  drizzle/; CMD is 'node migrate.mjs && node server.js'
- package.json: db:generate / db:migrate / db:studio / db:pull /
  db:build-migrator scripts; esbuild promoted to an explicit devDependency
- pnpm-lock.yaml resynced

BUG FIX: .dockerignore had 'drizzle/' — migration SQL was excluded from the
build context, so even a correct Dockerfile COPY would have found nothing.
This was the second half (with the .gitignore bug in commit 1) of why the
migration pipeline never worked. Now only _archived/_introspected are
excluded.

Verified: full docker build succeeds; runner image contains migrate.mjs +
drizzle baseline; migrator tested end-to-end against a scratch DB (35
tables created, __drizzle_migrations populated, idempotent on rerun).
This commit is contained in:
Manohar Gupta 2026-05-23 13:40:30 +05:30
parent edd239fa69
commit 9b9b551463
7 changed files with 124 additions and 20 deletions

View file

@ -6,5 +6,11 @@ README.md
.docker
docker-compose.dev.yml
docker/
drizzle/
*.log
dist
# NOTE: drizzle/ is intentionally NOT ignored — the migration SQL must be
# in the build context so the Dockerfile can COPY it into the runner image.
# Only the non-shipping sub-folders are excluded:
drizzle/_archived_pre_baseline_2026-05-19
drizzle/_introspected

3
.gitignore vendored
View file

@ -7,6 +7,9 @@
/.next/
/out/
# migrator build output (generated by scripts/build-migrator.mjs)
/dist/
# production
/build

View file

@ -14,6 +14,10 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable pnpm
RUN pnpm run build
# Bundle the standalone migration runner. This produces dist/migrate.mjs
# with drizzle-orm + postgres inlined, so the runner stage needs no extra
# node_modules. Runs here because the builder has full dependencies.
RUN pnpm run db:build-migrator
# Stage 3: Production runner
FROM node:22-alpine AS runner
@ -24,8 +28,15 @@ RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder /app/.next/static .next/static
# Migration runner + migration SQL. The migrator runs on container start
# (see CMD) BEFORE the Next.js server boots — see drizzle/README.md.
COPY --from=builder --chown=nextjs:nodejs /app/dist/migrate.mjs ./migrate.mjs
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
# Apply pending migrations, THEN start the server. If migration fails the
# process exits non-zero and the container crashes — a loud, safe failure
# that prevents the app from serving against a half-migrated schema.
CMD ["sh", "-c", "node migrate.mjs && node server.js"]

View file

@ -5,7 +5,12 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio",
"db:pull": "drizzle-kit pull",
"db:build-migrator": "node scripts/build-migrator.mjs"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.11.2",
@ -41,6 +46,7 @@
"@types/react-dom": "^19",
"@types/sharp": "^0.32.0",
"drizzle-kit": "^0.31.10",
"esbuild": "^0.25.12",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"

37
pnpm-lock.yaml generated
View file

@ -46,7 +46,7 @@ importers:
version: 5.0.0-beta.31(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@7.0.13)(react@19.2.4)
next-pwa:
specifier: ^5.6.0
version: 5.6.0(@babel/core@7.29.0)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.106.2)
version: 5.6.0(@babel/core@7.29.0)(esbuild@0.25.12)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.106.2(esbuild@0.25.12))
nodemailer:
specifier: ^7.0.13
version: 7.0.13
@ -102,6 +102,9 @@ importers:
drizzle-kit:
specifier: ^0.31.10
version: 0.31.10
esbuild:
specifier: ^0.25.12
version: 0.25.12
tailwindcss:
specifier: ^4
version: 4.3.0
@ -6302,14 +6305,14 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
babel-loader@8.4.1(@babel/core@7.29.0)(webpack@5.106.2):
babel-loader@8.4.1(@babel/core@7.29.0)(webpack@5.106.2(esbuild@0.25.12)):
dependencies:
'@babel/core': 7.29.0
find-cache-dir: 3.3.2
loader-utils: 2.0.4
make-dir: 3.1.0
schema-utils: 2.7.1
webpack: 5.106.2
webpack: 5.106.2(esbuild@0.25.12)
babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0):
dependencies:
@ -6417,10 +6420,10 @@ snapshots:
chrome-trace-event@1.0.4: {}
clean-webpack-plugin@4.0.0(webpack@5.106.2):
clean-webpack-plugin@4.0.0(webpack@5.106.2(esbuild@0.25.12)):
dependencies:
del: 4.1.1
webpack: 5.106.2
webpack: 5.106.2(esbuild@0.25.12)
client-only@0.0.1: {}
@ -7318,14 +7321,14 @@ snapshots:
optionalDependencies:
nodemailer: 7.0.13
next-pwa@5.6.0(@babel/core@7.29.0)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.106.2):
next-pwa@5.6.0(@babel/core@7.29.0)(esbuild@0.25.12)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.106.2(esbuild@0.25.12)):
dependencies:
babel-loader: 8.4.1(@babel/core@7.29.0)(webpack@5.106.2)
clean-webpack-plugin: 4.0.0(webpack@5.106.2)
babel-loader: 8.4.1(@babel/core@7.29.0)(webpack@5.106.2(esbuild@0.25.12))
clean-webpack-plugin: 4.0.0(webpack@5.106.2(esbuild@0.25.12))
globby: 11.1.0
next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
terser-webpack-plugin: 5.6.0(webpack@5.106.2)
workbox-webpack-plugin: 6.6.0(webpack@5.106.2)
terser-webpack-plugin: 5.6.0(esbuild@0.25.12)(webpack@5.106.2(esbuild@0.25.12))
workbox-webpack-plugin: 6.6.0(webpack@5.106.2(esbuild@0.25.12))
workbox-window: 6.6.0
transitivePeerDependencies:
- '@babel/core'
@ -7885,13 +7888,15 @@ snapshots:
type-fest: 0.16.0
unique-string: 2.0.0
terser-webpack-plugin@5.6.0(webpack@5.106.2):
terser-webpack-plugin@5.6.0(esbuild@0.25.12)(webpack@5.106.2(esbuild@0.25.12)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.47.1
webpack: 5.106.2
webpack: 5.106.2(esbuild@0.25.12)
optionalDependencies:
esbuild: 0.25.12
terser@5.47.1:
dependencies:
@ -8049,7 +8054,7 @@ snapshots:
webpack-sources@3.4.1: {}
webpack@5.106.2:
webpack@5.106.2(esbuild@0.25.12):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.9
@ -8072,7 +8077,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.3
terser-webpack-plugin: 5.6.0(webpack@5.106.2)
terser-webpack-plugin: 5.6.0(esbuild@0.25.12)(webpack@5.106.2(esbuild@0.25.12))
watchpack: 2.5.1
webpack-sources: 3.4.1
transitivePeerDependencies:
@ -8244,12 +8249,12 @@ snapshots:
workbox-sw@6.6.0: {}
workbox-webpack-plugin@6.6.0(webpack@5.106.2):
workbox-webpack-plugin@6.6.0(webpack@5.106.2(esbuild@0.25.12)):
dependencies:
fast-json-stable-stringify: 2.1.0
pretty-bytes: 5.6.0
upath: 1.2.0
webpack: 5.106.2
webpack: 5.106.2(esbuild@0.25.12)
webpack-sources: 1.4.3
workbox-build: 6.6.0
transitivePeerDependencies:

View file

@ -0,0 +1,30 @@
/**
* Bundles src/db/migrate.ts into a single self-contained dist/migrate.mjs.
*
* Why: the Next.js standalone production image does NOT keep drizzle-orm or
* postgres as separate node_modules packages (Next traces them straight into
* server.js). The migration runner is a separate entrypoint, so it needs its
* own bundle with those deps inlined. esbuild does exactly that.
*
* esbuild is already available as a transitive dependency of drizzle-kit/tsx,
* so this adds no new package to the project.
*
* Run via: pnpm db:build-migrator (invoked automatically inside the build).
*/
import { build } from "esbuild";
await build({
entryPoints: ["src/db/migrate.ts"],
bundle: true, // inline drizzle-orm + postgres into the output
platform: "node",
target: "node22",
format: "esm",
outfile: "dist/migrate.mjs",
// postgres ships optional native bits; keep it bundled but let node resolve
// built-ins normally. No externals — we want a fully standalone file.
banner: {
js: "// AUTO-GENERATED by scripts/build-migrator.mjs — do not edit.",
},
});
console.log("[build-migrator] dist/migrate.mjs written.");

43
src/db/migrate.ts Normal file
View file

@ -0,0 +1,43 @@
/**
* Standalone database migration runner.
*
* This runs as its OWN process on container startup, before the Next.js
* server boots (see Dockerfile CMD). It is intentionally separate from
* src/db/index.ts: that module configures a long-lived connection POOL for
* the running app; migrations want a single short-lived connection that is
* closed the moment they finish.
*
* Build note: this file is bundled by scripts/build-migrator.mjs into a
* self-contained dist/migrate.mjs (drizzle-orm + postgres inlined) so the
* production image needs no extra runtime dependencies.
*/
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
async function main() {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("[migrate] DATABASE_URL is not set — aborting.");
process.exit(1);
}
// max: 1 — a migration run is sequential; a single connection is correct
// and avoids holding pool slots. The connection is closed in finally{}.
const client = postgres(url, { max: 1 });
try {
console.log("[migrate] applying pending migrations...");
await migrate(drizzle(client), { migrationsFolder: "./drizzle" });
console.log("[migrate] done — schema is up to date.");
} catch (err) {
// Fail LOUD. If migration fails we must NOT let the app boot against a
// half-migrated schema — a crashed container is a visible, safe failure.
console.error("[migrate] FAILED:", err);
process.exit(1);
} finally {
await client.end();
}
}
main();