# Deployment

Multi-stage Docker build with Next.js `output: "standalone"`, runtime env injection, and a CI pipeline that gates on lint, typecheck, tests, and build.

## next.config.ts

```ts
// next.config.ts:1
import type { NextConfig } from "next";

const config: NextConfig = {
  output: "standalone",
  reactStrictMode: true,
  poweredByHeader: false,
  experimental: {
    typedRoutes: true,
  },
  transpilePackages: ["@mantine/core", "@mantine/hooks"],
};

export default config;
```

`output: "standalone"` produces a self-contained server with only the dependencies it needs — drop-in for Docker.

## Dockerfile

```dockerfile
# Dockerfile:1
# ---- Stage 1: deps ----
FROM oven/bun:1.1 AS deps
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile

# ---- Stage 2: builder ----
FROM oven/bun:1.1 AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NEXT_TELEMETRY_DISABLED=1
RUN bun run build

# ---- Stage 3: runner ----
FROM gcr.io/distroless/nodejs20-debian12 AS runner
WORKDIR /app

ENV NODE_ENV=production \
    NEXT_TELEMETRY_DISABLED=1 \
    PORT=3000 \
    HOSTNAME=0.0.0.0

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

EXPOSE 3000

USER nonroot
CMD ["server.js"]
```

The standalone build at `.next/standalone/server.js` includes its own `node_modules` subset — no need to copy `node_modules`.

## Build Args and Runtime Env

Public env (`NEXT_PUBLIC_*`) is **baked at build time** by Next.js. Any change requires a rebuild.

Server-only env (`BETTER_AUTH_*`, `KEYCLOAK_*`, `DATABASE_URL`) is **runtime-injected**. Pass them via Kubernetes `env` or Docker `--env-file`.

```yaml
# k8s deployment excerpt
env:
  - name: BETTER_AUTH_SECRET
    valueFrom:
      secretKeyRef:
        name: dashboard-fe-secrets
        key: better-auth-secret
  - name: DATABASE_URL
    valueFrom:
      secretKeyRef:
        name: dashboard-fe-secrets
        key: database-url
  - name: KEYCLOAK_ISSUER
    value: "https://auth.example.com/realms/production"
  - name: NEXT_PUBLIC_API_BASE_URL
    value: "https://api.example.com"
```

`NEXT_PUBLIC_API_BASE_URL` is set at runtime via `NEXT_PUBLIC_*` overriding — Next.js supports runtime env for these in standalone mode when using `process.env.NEXT_PUBLIC_*` reads at request time (server components / RSC), but anything inlined at build time still uses the build-time value. To allow runtime overrides of public envs, read them server-side and pass via headers or cookies. For most internal dashboards, set `NEXT_PUBLIC_*` at build time and treat them as immutable per environment.

## Secrets Source

| Environment | Source |
|-------------|--------|
| `local` | `.env.local` (never committed) |
| `staging` | OpenBao v2 KV via CI or ArgoCD |
| `production` | OpenBao v2 KV via External Secrets Operator |

The team's OpenBao setup is documented in [backend-dev CONFIGURATION](../../backend-dev/references/CONFIGURATION.md). Mirror the same conventions.

## CI Pipeline

```yaml
# .github/workflows/ci.yml:1
name: CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: dashboard
          POSTGRES_PASSWORD: dashboard
          POSTGRES_DB: dashboard_test
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    env:
      DATABASE_URL: postgres://dashboard:dashboard@localhost:5432/dashboard_test
      BETTER_AUTH_SECRET: ci-test-secret-not-for-production-use-32bytes
      BETTER_AUTH_URL: http://localhost:3000
      KEYCLOAK_ISSUER: https://auth.example.com/realms/ci
      KEYCLOAK_CLIENT_ID: dashboard-fe
      KEYCLOAK_CLIENT_SECRET: ci-secret
      NEXT_PUBLIC_API_BASE_URL: http://localhost:8080

    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v1
        with:
          bun-version: 1.1

      - run: bun install --frozen-lockfile

      - name: Lint
        run: bun run lint

      - name: Typecheck
        run: bun run typecheck

      - name: Format check
        run: bun run format:check

      - name: Better Auth migrations
        run: bun x @better-auth/cli@latest migrate --yes

      - name: Unit + integration tests
        run: bun run test:coverage

      - name: Build
        run: bun run build

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

  e2e:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v1
        with:
          bun-version: 1.1
      - run: bun install --frozen-lockfile
      - run: bunx playwright install --with-deps chromium
      - run: bun run test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-traces
          path: test-results/
```

## Health and Readiness

Expose two endpoints (Next.js route handlers):

```ts
// src/app/api/healthz/route.ts:1
export const dynamic = "force-dynamic";

export function GET() {
  return Response.json({ status: "ok" });
}
```

```ts
// src/app/api/readyz/route.ts:1
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export const dynamic = "force-dynamic";

export async function GET() {
  try {
    await pool.query("SELECT 1");
    return Response.json({ status: "ready" });
  } catch {
    return Response.json({ status: "not_ready" }, { status: 503 });
  }
}
```

Wire Kubernetes probes:

```yaml
livenessProbe:
  httpGet: { path: /api/healthz, port: 3000 }
  periodSeconds: 10
readinessProbe:
  httpGet: { path: /api/readyz, port: 3000 }
  periodSeconds: 5
  failureThreshold: 3
```

## Migrations on Boot

Better Auth tables are managed by the CLI, not automatically on boot. Two options:

1. **CI step** (recommended) — run `bun x @better-auth/cli@latest migrate --yes` as part of the deploy pipeline before rolling out new pods.
2. **Init container** — run migrations in a Kubernetes `initContainer` that uses the same image and exits 0 on success.

Do not run migrations from the application process on boot — concurrent rollouts cause race conditions.

## Image Size and Security

- Distroless runtime: ~100 MB.
- Run as `nonroot` (Distroless ships with this user).
- No shell in the runtime image — debug by attaching `kubectl debug` or running a sidecar.
- `.dockerignore`:

```
.git
.github
.next
node_modules
coverage
e2e
*.md
!README.md
```

## Observability

- **Logs** — `console.error` (server) goes to stdout/stderr. Configure the cluster to ship to your central log store.
- **Metrics** — `/api/metrics` returns Prometheus text. See [backend-dev ARCHITECTURE](../../backend-dev/references/ARCHITECTURE.md) for the same pattern; mirror it on the frontend for HTTP request metrics if needed.
- **Tracing** — OpenTelemetry browser SDK for RSC fetches. Tag spans with `dashboard-fe`.

## Rollback

Tag every image with the git SHA. Keep the previous 5 images in the registry. Rollback = redeploy the previous tag. No DB migrations are reversible in Better Auth without manual SQL — treat schema changes carefully.

## Local Production Smoke Test

```bash
docker build -t dashboard-fe:local .
docker run --rm -p 3000:3000 \
  -e BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
  -e BETTER_AUTH_URL=http://localhost:3000 \
  -e DATABASE_URL=postgres://user:pass@host.docker.internal:5432/dashboard \
  -e KEYCLOAK_ISSUER=https://auth.example.com/realms/production \
  -e KEYCLOAK_CLIENT_ID=dashboard-fe \
  -e KEYCLOAK_CLIENT_SECRET=changeme \
  -e NEXT_PUBLIC_API_BASE_URL=http://host.docker.internal:8080 \
  dashboard-fe:local
```

Verify `/api/healthz` returns 200, sign-in flow redirects to Keycloak, and one protected route loads data from the backend.