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:
parent
870128c16e
commit
2b18c99296
2 changed files with 56 additions and 17 deletions
16
Dockerfile
16
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue