diff --git a/Dockerfile b/Dockerfile index 5484443..31d28c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,13 @@ COPY package*.json ./ RUN npm ci COPY . . + +# next build writes the rewrite destinations from next.config.mjs into +# .next/routes-manifest.json, so "next start" cannot pick up a different +# API URL later. The value has to be known here, not just at runtime. +ARG API_INTERNAL_URL=http://localhost:3000 +ENV API_INTERNAL_URL=$API_INTERNAL_URL + RUN npm run build:api && npm run build:web RUN mkdir -p data && chmod +x scripts/docker-start.sh diff --git a/README.md b/README.md index d7a7b09..fc82fca 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,24 @@ docker compose logs --follow docker compose down ``` -Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein -Produktionsdeployment. Details stehen in +`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`, +ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in [Deployment und Betrieb](docs/deployment.md). +Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit +Quellcode-Mounts und Watchern: + +```powershell +docker compose -f compose.dev.yaml up --build --detach +docker compose -f compose.dev.yaml logs --follow +docker compose -f compose.dev.yaml down +``` + +Die Watcher darin laufen im Polling-Modus, weil Bind-Mounts unter Windows und +macOS keine inotify-Events durchreichen. Das kostet dauerhaft CPU, auch wenn +niemand die Anwendung benutzt — deshalb gehört dieser Stack nicht auf einen +Server. + ## Direkte lokale Entwicklung Voraussetzungen: diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..86e42e2 --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,87 @@ +# Development stack: source mounts, watching dev servers, hot reload. +# docker compose -f compose.dev.yaml up --build +# +# The polling watchers below are needed for bind mounts on Windows and +# macOS, where inotify events do not cross the VM boundary. They cost +# continuous CPU, which is why the production stack in compose.yaml does +# not run watchers at all. +name: leistungsbilanz-dev + +x-logging: &logging + driver: json-file + options: + max-size: "20m" + max-file: "10" + +services: + api: + build: + context: . + command: + - sh + - -c + - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api + environment: + PORT: "3000" + CHOKIDAR_USEPOLLING: "true" + LOG_LEVEL: "${LOG_LEVEL:-debug}" + init: true + restart: unless-stopped + logging: *logging + ports: + - "3000:3000" + volumes: + - ./src:/app/src + - ./scripts:/app/scripts + - ./data:/app/data + - ./drizzle.config.ts:/app/drizzle.config.ts:ro + - ./tsconfig.json:/app/tsconfig.json:ro + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 30s + timeout: 3s + retries: 5 + start_period: 20s + + web: + build: + context: . + command: + - npm + - run + - dev:web + - -- + - --hostname + - 0.0.0.0 + environment: + API_INTERNAL_URL: http://api:3000 + WATCHPACK_POLLING: "true" + NEXT_TELEMETRY_DISABLED: "1" + LOG_LEVEL: "${LOG_LEVEL:-debug}" + init: true + restart: unless-stopped + logging: *logging + depends_on: + api: + condition: service_healthy + ports: + - "3001:3001" + volumes: + - ./src:/app/src + - ./next.config.mjs:/app/next.config.mjs:ro + - ./tsconfig.json:/app/tsconfig.json:ro + - ./tsconfig.next.json:/app/tsconfig.next.json:ro + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 30s + timeout: 3s + retries: 5 + start_period: 20s diff --git a/compose.yaml b/compose.yaml index 6a5ec7c..503a4b2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,82 +1,73 @@ name: leistungsbilanz +x-build: &build + context: . + args: + # Baked into .next/routes-manifest.json by next build; see Dockerfile. + API_INTERNAL_URL: http://api:3000 + +x-logging: &logging + driver: json-file + options: + max-size: "20m" + max-file: "10" + services: api: - build: - context: . + build: *build command: - sh - -c - - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api + - node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js environment: + NODE_ENV: production PORT: "3000" - CHOKIDAR_USEPOLLING: "true" LOG_LEVEL: "${LOG_LEVEL:-info}" init: true restart: unless-stopped - logging: - driver: json-file - options: - max-size: "20m" - max-file: "10" + logging: *logging ports: - "3000:3000" volumes: - - ./src:/app/src - - ./scripts:/app/scripts - ./data:/app/data - - ./drizzle.config.ts:/app/drizzle.config.ts:ro - - ./tsconfig.json:/app/tsconfig.json:ro healthcheck: test: - CMD - node - -e - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 5s + interval: 30s timeout: 3s - retries: 12 + retries: 5 start_period: 20s web: - build: - context: . + build: *build command: - - npm - - run - - dev:web - - -- - - --hostname - - 0.0.0.0 + - node_modules/.bin/next + - start + - -p + - "3001" environment: + NODE_ENV: production API_INTERNAL_URL: http://api:3000 - WATCHPACK_POLLING: "true" NEXT_TELEMETRY_DISABLED: "1" LOG_LEVEL: "${LOG_LEVEL:-info}" init: true restart: unless-stopped - logging: - driver: json-file - options: - max-size: "20m" - max-file: "10" + logging: *logging depends_on: api: condition: service_healthy ports: - "3001:3001" - volumes: - - ./src:/app/src - - ./next.config.mjs:/app/next.config.mjs:ro - - ./tsconfig.json:/app/tsconfig.json:ro - - ./tsconfig.next.json:/app/tsconfig.next.json:ro healthcheck: test: - CMD - node - -e - - fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 5s + - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 30s timeout: 3s - retries: 12 + retries: 5 start_period: 20s diff --git a/docs/deployment.md b/docs/deployment.md index dfbb3f1..06289a7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,19 +2,27 @@ ## Aktueller Status -Es gibt derzeit kein unterstütztes Produktionsdeployment. +Es gibt zwei Compose-Stacks. -`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet -`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS, -Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes -Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder -öffentlich erreichbar gemacht werden. +`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und +`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack +für einen Server. -## Entwicklungs-Topologie +`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom +Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows +und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch +ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen +Entwicklungsrechner. + +Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch +ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich +erreichbar gemacht werden. + +## Topologie | Komponente | Port | Healthcheck | Persistenz | | --- | ---: | --- | --- | -| Next.js Web | 3001 | `GET /` | keine | +| Next.js Web | 3001 | `GET /web-health` | keine | | Express API | 3000 | `GET /health` | `./data:/app/data` | | SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | @@ -24,15 +32,22 @@ Verwendete Umgebungsvariablen: - `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz `http://api:3000` - `NEXT_TELEMETRY_DISABLED=1` -- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale - Dateibeobachtung in Docker +- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in + `compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg - `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. Setzbar über eine `.env`-Datei neben `compose.yaml` oder `LOG_LEVEL=verbose docker compose up`. -Beim API-Start laufen zuerst `npm run db:migrate` und -`npm run db:verify:circuit-schema`. +Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung +(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im +Entwicklungsstack über `npm run db:migrate` und +`npm run db:verify:circuit-schema`). + +`API_INTERNAL_URL` wirkt für `next start` zur **Build-Zeit**: `next build` +schreibt die Rewrite-Ziele aus `next.config.mjs` fest in +`.next/routes-manifest.json`. `compose.yaml` reicht den Wert deshalb als +Build-Argument an das Image durch, nicht nur als Laufzeit-Variable. ## Logging diff --git a/package.json b/package.json index d5ebe82..414eb53 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "docker:up": "docker compose up --build --detach", "docker:down": "docker compose down", "docker:logs": "docker compose logs --follow", + "docker:dev:up": "docker compose -f compose.dev.yaml up --build --detach", + "docker:dev:down": "docker compose -f compose.dev.yaml down", + "docker:dev:logs": "docker compose -f compose.dev.yaml logs --follow", "build": "npm run build:api", "build:api": "tsc -p tsconfig.json", "build:web": "next build", diff --git a/src/app/web-health/route.ts b/src/app/web-health/route.ts new file mode 100644 index 0000000..ce6f2c4 --- /dev/null +++ b/src/app/web-health/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; + +// Liveness probe for the web container itself. The "/health" path is +// rewritten to the API in next.config.mjs, so it cannot answer for this +// process. Kept as a route handler so a probe does not render a page. +export const dynamic = "force-dynamic"; + +export function GET() { + return NextResponse.json({ ok: true }); +} diff --git a/src/proxy.ts b/src/proxy.ts index 4607ec0..3f4b83a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -5,8 +5,9 @@ import { createLogger } from "./shared/logging/logger"; const logger = createLogger("web:navigation"); export function proxy(request: NextRequest) { - // The Docker healthcheck hits "/" every few seconds with no User-Agent - // header; skip it so real navigation isn't drowned out in the logs. + // Probes hit "/web-health" and are excluded by the matcher below. The + // User-Agent guard stays as a fallback for anything else that polls + // without one, so real navigation isn't drowned out in the logs. if (request.headers.get("user-agent")) { logger.info("page request", { method: request.method, @@ -17,5 +18,7 @@ export function proxy(request: NextRequest) { } export const config = { - matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"], + matcher: [ + "/((?!_next/static|_next/image|favicon.ico|api|web-health).*)", + ], };