Add deployment infrastructure: health endpoint, Docker Compose fixes, tunnel

- Add /api/health endpoint checking DB, pgvector, org, templates,
  dev bypass safety, and AUTH_SECRET presence
- Fix Docker Compose app service: AUTH_SECRET, Entra ID env vars,
  AUTH_TRUST_HOST, app health check
- Add Cloudflare Tunnel service for zero-config HTTPS access
- Exclude health endpoint from auth middleware

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leivur Djurhuus 2026-04-06 14:54:15 -05:00
parent 0eaf809bc6
commit fa55dfc25f
3 changed files with 116 additions and 4 deletions

View file

@ -57,8 +57,12 @@ services:
OLLAMA_EMBED_MODEL: nomic-embed-text
OLLAMA_LLM_MODEL: qwen3:1.7b
NODE_ENV: production
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-dev-secret-change-in-production}
AUTH_SECRET: ${AUTH_SECRET}
AUTH_TRUST_HOST: "true"
AUTH_MICROSOFT_ENTRA_ID_ID: ${AUTH_MICROSOFT_ENTRA_ID_ID}
AUTH_MICROSOFT_ENTRA_ID_SECRET: ${AUTH_MICROSOFT_ENTRA_ID_SECRET}
AUTH_MICROSOFT_ENTRA_ID_TENANT_ID: ${AUTH_MICROSOFT_ENTRA_ID_TENANT_ID}
CRON_SECRET: ${CRON_SECRET:-change-me}
volumes:
- uploads_data:/data/uploads
depends_on:
@ -66,6 +70,25 @@ services:
condition: service_healthy
ollama:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
profiles:
- production
# ─── Cloudflare Tunnel (HTTPS access without port forwarding) ──
tunnel:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
depends_on:
app:
condition: service_healthy
profiles:
- production

View file

@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function GET() {
const checks: Record<string, { status: "ok" | "fail"; detail?: string }> = {};
let healthy = true;
// 1. Database connectivity
try {
await prisma.$queryRawUnsafe("SELECT 1");
checks.database = { status: "ok" };
} catch (e: any) {
checks.database = { status: "fail", detail: e.message };
healthy = false;
}
// 2. pgvector extension
try {
const result: any[] = await prisma.$queryRawUnsafe(
"SELECT extversion FROM pg_extension WHERE extname = 'vector'"
);
if (result.length > 0) {
checks.pgvector = { status: "ok", detail: `v${result[0].extversion}` };
} else {
checks.pgvector = { status: "fail", detail: "extension not installed" };
healthy = false;
}
} catch {
checks.pgvector = { status: "fail", detail: "query failed" };
healthy = false;
}
// 3. Organization exists
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;
}
} catch {
checks.organization = { status: "fail", detail: "query failed" };
healthy = false;
}
// 4. Pipeline templates exist
try {
const templateCount = await prisma.pipelineTemplate.count();
checks.pipeline_templates = {
status: templateCount > 0 ? "ok" : "fail",
detail: `${templateCount} template(s)`,
};
if (templateCount === 0) healthy = false;
} catch {
checks.pipeline_templates = { status: "fail", detail: "query failed" };
healthy = false;
}
// 5. DEV_BYPASS_AUTH safety
const devBypassActive =
process.env.DEV_BYPASS_AUTH === "true" && process.env.NODE_ENV === "production";
if (devBypassActive) {
checks.dev_bypass = { status: "fail", detail: "DEV_BYPASS_AUTH=true in production!" };
healthy = false;
} else {
checks.dev_bypass = { status: "ok" };
}
// 6. AUTH_SECRET set (required for next-auth v5)
if (process.env.NODE_ENV === "production" && !process.env.AUTH_SECRET) {
checks.auth_secret = { status: "fail", detail: "AUTH_SECRET not set" };
healthy = false;
} else {
checks.auth_secret = { status: "ok" };
}
return NextResponse.json(
{
status: healthy ? "healthy" : "unhealthy",
checks,
timestamp: new Date().toISOString(),
},
{ status: healthy ? 200 : 503 }
);
}

View file

@ -11,9 +11,10 @@ export function middleware(request: NextRequest) {
const isAuthPage = pathname.startsWith("/login");
const isPendingPage = pathname.startsWith("/pending");
const isApiAuth = pathname.startsWith("/api/auth");
const isHealthCheck = pathname === "/api/health";
// Always allow auth API routes
if (isApiAuth) {
// Always allow auth API routes and health checks
if (isApiAuth || isHealthCheck) {
return NextResponse.next();
}