From f1af4b517196632a47d78b3e48fe475d5399b6e5 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:07:36 -0400 Subject: [PATCH] Decouple database migrations from server startup (#265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use Docker Compose init service pattern for database migrations Replaces the fragile double-migration approach (server.ts startup + drizzle-kit CLI in CI) with a one-shot migrate service in Docker Compose that the app depends on via condition: service_completed_successfully. - Add scripts/migrate.mjs: programmatic drizzle-orm migration (uses production deps, not drizzle-kit which is devOnly and absent from image) - Dockerfile: copy scripts/ into image, remove drizzle.config.ts (only needed by drizzle-kit CLI) - server.ts: remove runMigrations() entirely; migrations are now handled by the migrate container before the app starts - deploy.yml: remove explicit docker run migration step; replace sleep 10 health check with a poll loop and explicit migrate exit-code check Production server's docker-compose.yaml needs a one-time manual update to add the migrate service — see plan for exact config. https://claude.ai/code/session_01ReaqH3o9NVH4QU4qE9WMMQ * Move drizzle-kit to devDependencies; use docker compose wait in deploy drizzle-kit is a dev-only tool (schema generation and local migrations). Now that production migrations run via the programmatic drizzle-orm API in the migrate init container, drizzle-kit has no runtime role. Also replaces the polling health check loop with docker compose wait, which blocks until the migrate service exits and returns its exit code cleanly — no sleep or manual status inspection needed. https://claude.ai/code/session_01ReaqH3o9NVH4QU4qE9WMMQ --------- Co-authored-by: Claude --- .github/workflows/deploy.yml | 15 ++++++--------- Dockerfile | 2 +- package.json | 2 +- scripts/migrate.mjs | 26 ++++++++++++++++++++++++++ server.ts | 27 ++++----------------------- 5 files changed, 38 insertions(+), 34 deletions(-) create mode 100644 scripts/migrate.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4f09bcc..5e86976 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -138,24 +138,21 @@ jobs: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} CONTAINER_REGISTRY: ${{ vars.CONTAINER_REGISTRY }} - PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} with: host: ${{ secrets.DEPLOY_HOST }} port: ${{ secrets.DEPLOY_PORT }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_KEY }} - envs: REGISTRY_USERNAME,REGISTRY_PASSWORD,CONTAINER_REGISTRY,PROD_DATABASE_URL + envs: REGISTRY_USERNAME,REGISTRY_PASSWORD,CONTAINER_REGISTRY script: | set -e - IMAGE="$CONTAINER_REGISTRY/brackt:latest" docker login $CONTAINER_REGISTRY -u $REGISTRY_USERNAME -p $REGISTRY_PASSWORD cd brackt docker compose pull - docker run --rm -e DATABASE_URL="$PROD_DATABASE_URL" "$IMAGE" npx drizzle-kit migrate - docker compose up -d - sleep 10 - if docker compose ps | grep -qE "Exit|exited"; then - echo "One or more containers failed to start:" - docker compose logs --tail=50 + docker compose up -d migrate + if ! docker compose wait migrate | grep -qx "0"; then + echo "Migration failed:" + docker compose logs migrate exit 1 fi + docker compose up -d --remove-orphans diff --git a/Dockerfile b/Dockerfile index a7d47a8..79a6d8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ COPY --from=production-dependencies-env /app/node_modules /app/node_modules COPY --from=build-env /app/build /app/build COPY --from=build-env /app/dist /app/dist COPY ./drizzle /app/drizzle -COPY ./drizzle.config.ts /app/drizzle.config.ts +COPY ./scripts /app/scripts COPY ./instrument.server.mjs /app/instrument.server.mjs WORKDIR /app CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/package.json b/package.json index 92e0d74..118e9f6 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "clsx": "^2.1.1", "compression": "^1.8.0", "date-fns": "^4.1.0", - "drizzle-kit": "~0.28.1", "drizzle-orm": "~0.36.3", "express": "^5.1.0", "isbot": "^5.1.27", @@ -103,6 +102,7 @@ "@vitest/ui": "^3.2.4", "cypress": "^14.5.4", "dotenv-cli": "^8.0.0", + "drizzle-kit": "~0.28.1", "esbuild": "^0.25.11", "jsdom": "^27.0.1", "oxlint": "^1.56.0", diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs new file mode 100644 index 0000000..89cd316 --- /dev/null +++ b/scripts/migrate.mjs @@ -0,0 +1,26 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; +import { fileURLToPath } from "url"; +import path from "path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +if (!process.env.DATABASE_URL) { + console.error("ERROR: DATABASE_URL is required"); + process.exit(1); +} + +console.log("Running database migrations..."); +const client = postgres(process.env.DATABASE_URL, { max: 1 }); +try { + const db = drizzle(client); + await migrate(db, { migrationsFolder: path.resolve(__dirname, "../drizzle") }); + console.log("Migrations completed successfully"); +} catch (err) { + console.error("Migration failed:", err); + process.exit(1); +} finally { + await client.end(); +} diff --git a/server.ts b/server.ts index d921101..d36fd98 100644 --- a/server.ts +++ b/server.ts @@ -6,9 +6,6 @@ import { createServer } from "http"; import type { ViteDevServer } from "vite"; import path from "path"; import { fileURLToPath } from "url"; -import { drizzle } from "drizzle-orm/postgres-js"; -import { migrate } from "drizzle-orm/postgres-js/migrator"; -import postgres from "postgres"; import { logger } from "./server/logger"; // ESM module resolution helpers @@ -24,20 +21,6 @@ const CLIENT_ASSETS_PATH = path.join(PROJECT_ROOT, "build/client/assets"); const DEVELOPMENT = process.env.NODE_ENV === "development"; const PORT = Number.parseInt(process.env.PORT || "3000", 10); -async function runMigrations(): Promise { - if (!process.env.DATABASE_URL) { - throw new Error("DATABASE_URL is required"); - } - const client = postgres(process.env.DATABASE_URL); - try { - const db = drizzle(client); - await migrate(db, { migrationsFolder: path.join(PROJECT_ROOT, "drizzle") }); - logger.log("Migrations complete"); - } finally { - await client.end(); - } -} - async function createAppServer(): Promise { const app: Express = express(); @@ -100,9 +83,7 @@ async function createAppServer(): Promise { } // Start the server with error handling -(DEVELOPMENT ? Promise.resolve() : runMigrations()) - .then(createAppServer) - .catch((error) => { - logger.error("Failed to start server:", error); - process.exit(1); - }); +createAppServer().catch((error) => { + logger.error("Failed to start server:", error); + process.exit(1); +});