Fix stuck deploy: seed deps missing from prod image + health check too strict

Caught on the first real deploy to optical-dev. Two separate bugs.

Dockerfile — runner stage was missing tsx + @prisma/adapter-pg + bcryptjs
   The Next.js standalone bundle covers the app, but prisma/seed-dow.ts
   is a separate .ts file executed via tsx (not bundled). Runner only
   explicitly installed prisma + dotenv, so `npm run db:seed` failed with
   "sh: tsx: not found" and deploys couldn't run the one-time seed.
   → Added tsx, @prisma/adapter-pg (seed uses PrismaPg directly), and
     bcryptjs (seed hashes the admin's temp password) to the
     `npm install --no-save` line in the runner stage. Adds ~15 MB to
     the final image — worth it for a working seed path.

/api/health was 503 pre-seed, which made deploy.sh unwillingly block itself
   The probe in deploy.sh uses `curl -sf` and treats any non-2xx as
   "not ready". The health endpoint flipped the entire `healthy` flag to
   false when `organizations` or `pipeline_templates` counted zero —
   meaning a freshly-migrated-but-not-yet-seeded app was classified as
   unhealthy, deploy.sh gave up at Step 6, and we never got to Step 7
   (Apache config) or Step 8 (UFW). End result: the URL 404'd because
   Apache wasn't proxying anything to the container.
   → Split liveness from readiness:
     - GET /api/health (default) — DB reachable, pgvector installed,
       AUTH_SECRET set, DEV_BYPASS off. Empty tables are reported as
       "warn" but do NOT 503. This is what deploy.sh waits on.
     - GET /api/health?strict=1 — same checks PLUS org + templates
       present. Use post-seed to verify everything landed.
   - Added a "mode" field ("liveness" | "strict") so which mode was
     used is visible in the response.
   - Pre-seed content-level checks now return status: "warn" with a
     hint to run `npm run db:seed`, instead of hard-failing.

Net effect for a fresh deploy:
  ./deploy.sh → builds, runs migrations, reports healthy once DB +
  env are good, configures Apache, DONE. Then you can
  `docker compose -p dow-prod-tracker exec app npm run db:seed`
  at your leisure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-20 20:45:44 -04:00
parent 870128c16e
commit 2b18c99296
2 changed files with 56 additions and 17 deletions

View file

@ -42,8 +42,20 @@ COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/src/generated ./src/generated
# Install prisma + dotenv for migrate deploy (prisma.config.ts imports dotenv/config)
RUN npm install --no-save prisma@7.4.2 dotenv@17.3.1
# Install runtime deps needed for migrations + seed. The Next.js standalone
# bundle covers the app itself, but the seed script (prisma/seed-dow.ts) is
# a separate .ts file executed via tsx and needs its own module graph:
# prisma — the migrate-deploy CLI
# dotenv — prisma.config.ts imports dotenv/config
# tsx — runs the seed without an ahead-of-time compile
# @prisma/adapter-pg — the driver the seed instantiates directly
# bcryptjs — the seed hashes the admin's temp password
RUN npm install --no-save \
prisma@7.4.2 \
dotenv@17.3.1 \
tsx@4 \
@prisma/adapter-pg@7.4.2 \
bcryptjs@3
# Create uploads directories for media storage
RUN mkdir -p /data/uploads && chown nextjs:nodejs /data/uploads

View file

@ -3,11 +3,27 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function GET() {
const checks: Record<string, { status: "ok" | "fail"; detail?: string }> = {};
/**
* GET /api/health
*
* Liveness probe by default returns 200 as long as the app is responsive
* and correctly configured. Empty tables (no orgs, no templates) are
* reported but do NOT flip the status to unhealthy a freshly-deployed
* tracker hasn't run its seed yet, and that's not a runtime failure.
*
* Pass `?strict=1` for the full readiness check including seed content
* useful after running the seed to confirm everything landed.
*/
export async function GET(request: Request) {
const strict = new URL(request.url).searchParams.get("strict") === "1";
const checks: Record<
string,
{ status: "ok" | "warn" | "fail"; detail?: string }
> = {};
let healthy = true;
// 1. Database connectivity
// 1. Database connectivity — liveness-critical
try {
await prisma.$queryRawUnsafe("SELECT 1");
checks.database = { status: "ok" };
@ -16,7 +32,7 @@ export async function GET() {
healthy = false;
}
// 2. pgvector extension
// 2. pgvector extension — liveness-critical (embeddings + search need it)
try {
const result: any[] = await prisma.$queryRawUnsafe(
"SELECT extversion FROM pg_extension WHERE extname = 'vector'"
@ -32,34 +48,44 @@ export async function GET() {
healthy = false;
}
// 3. Organization exists
// 3. Organization exists — content-level, only blocks health in strict mode
try {
const orgCount = await prisma.organization.count();
if (orgCount > 0) {
checks.organization = { status: "ok", detail: `${orgCount} org(s)` };
} else {
checks.organization = { status: "fail", detail: "no organizations found" };
healthy = false;
checks.organization = {
status: "warn",
detail: "no organizations — run `npm run db:seed`",
};
if (strict) healthy = false;
}
} catch {
checks.organization = { status: "fail", detail: "query failed" };
healthy = false;
}
// 4. Pipeline templates exist
// 4. Pipeline templates — content-level, only blocks health in strict mode
try {
const templateCount = await prisma.pipelineTemplate.count();
checks.pipeline_templates = {
status: templateCount > 0 ? "ok" : "fail",
detail: `${templateCount} template(s)`,
};
if (templateCount === 0) healthy = false;
if (templateCount > 0) {
checks.pipeline_templates = {
status: "ok",
detail: `${templateCount} template(s)`,
};
} else {
checks.pipeline_templates = {
status: "warn",
detail: "no pipeline templates — run `npm run db:seed`",
};
if (strict) healthy = false;
}
} catch {
checks.pipeline_templates = { status: "fail", detail: "query failed" };
healthy = false;
}
// 5. DEV_BYPASS_AUTH safety
// 5. DEV_BYPASS_AUTH safety — always a hard failure in production
const devBypassActive =
process.env.DEV_BYPASS_AUTH === "true" && process.env.NODE_ENV === "production";
if (devBypassActive) {
@ -69,7 +95,7 @@ export async function GET() {
checks.dev_bypass = { status: "ok" };
}
// 6. AUTH_SECRET set (required for next-auth v5)
// 6. AUTH_SECRET set — liveness-critical (NextAuth can't sign anything without it)
if (process.env.NODE_ENV === "production" && !process.env.AUTH_SECRET) {
checks.auth_secret = { status: "fail", detail: "AUTH_SECRET not set" };
healthy = false;
@ -80,6 +106,7 @@ export async function GET() {
return NextResponse.json(
{
status: healthy ? "healthy" : "unhealthy",
mode: strict ? "strict" : "liveness",
checks,
timestamp: new Date().toISOString(),
},