Run the Docker stack on built output instead of dev servers

compose.yaml overrode the image's own entrypoint to start "tsx watch" and
"next dev" with CHOKIDAR_USEPOLLING and WATCHPACK_POLLING enabled. The
built dist/ and .next from the image were never used, and the polling
watchers kept both containers busy around the clock whether or not
anyone used the application.

Split the two use cases: compose.yaml now runs node dist/server/index.js
and next start with no source mounts and no watchers, and the watching
setup moves to compose.dev.yaml.

next build writes the rewrite destinations from next.config.mjs into
.next/routes-manifest.json, so next start cannot pick up API_INTERNAL_URL
at runtime the way next dev does. Pass it as a build argument.

Give the web container its own probe. The healthcheck hit "/", which
redirects to "/projects", and fetch follows redirects, so every probe
rendered two pages every five seconds. "/health" could not serve as the
probe because next.config.mjs rewrites it to the API. Add "/web-health"
as a route handler, exclude it from the proxy matcher, and relax both
intervals to 30s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-08-23 18:25:06 +02:00
parent c6cdfc42d5
commit 906aa751c7
8 changed files with 184 additions and 54 deletions

View file

@ -5,6 +5,13 @@ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . 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 npm run build:api && npm run build:web
RUN mkdir -p data && chmod +x scripts/docker-start.sh RUN mkdir -p data && chmod +x scripts/docker-start.sh

View file

@ -62,10 +62,24 @@ docker compose logs --follow
docker compose down docker compose down
``` ```
Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein `compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`,
Produktionsdeployment. Details stehen in ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in
[Deployment und Betrieb](docs/deployment.md). [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 ## Direkte lokale Entwicklung
Voraussetzungen: Voraussetzungen:

87
compose.dev.yaml Normal file
View file

@ -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

View file

@ -1,82 +1,73 @@
name: leistungsbilanz 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: services:
api: api:
build: build: *build
context: .
command: command:
- sh - sh
- -c - -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: environment:
NODE_ENV: production
PORT: "3000" PORT: "3000"
CHOKIDAR_USEPOLLING: "true"
LOG_LEVEL: "${LOG_LEVEL:-info}" LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true init: true
restart: unless-stopped restart: unless-stopped
logging: logging: *logging
driver: json-file
options:
max-size: "20m"
max-file: "10"
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
- ./src:/app/src
- ./scripts:/app/scripts
- ./data:/app/data - ./data:/app/data
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
- ./tsconfig.json:/app/tsconfig.json:ro
healthcheck: healthcheck:
test: test:
- CMD - CMD
- node - node
- -e - -e
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 5s interval: 30s
timeout: 3s timeout: 3s
retries: 12 retries: 5
start_period: 20s start_period: 20s
web: web:
build: build: *build
context: .
command: command:
- npm - node_modules/.bin/next
- run - start
- dev:web - -p
- -- - "3001"
- --hostname
- 0.0.0.0
environment: environment:
NODE_ENV: production
API_INTERNAL_URL: http://api:3000 API_INTERNAL_URL: http://api:3000
WATCHPACK_POLLING: "true"
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
LOG_LEVEL: "${LOG_LEVEL:-info}" LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true init: true
restart: unless-stopped restart: unless-stopped
logging: logging: *logging
driver: json-file
options:
max-size: "20m"
max-file: "10"
depends_on: depends_on:
api: api:
condition: service_healthy condition: service_healthy
ports: ports:
- "3001:3001" - "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: healthcheck:
test: test:
- CMD - CMD
- node - node
- -e - -e
- fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 5s interval: 30s
timeout: 3s timeout: 3s
retries: 12 retries: 5
start_period: 20s start_period: 20s

View file

@ -2,19 +2,27 @@
## Aktueller Status ## 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 `compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und
`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS, `next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack
Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes für einen Server.
Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder
öffentlich erreichbar gemacht werden.
## 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 | | 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` | | Express API | 3000 | `GET /health` | `./data:/app/data` |
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | | 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 - `API_INTERNAL_URL` internes API-Ziel des Next.js-Rewrites, im Compose-Netz
`http://api:3000` `http://api:3000`
- `NEXT_TELEMETRY_DISABLED=1` - `NEXT_TELEMETRY_DISABLED=1`
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale - `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` nur in
Dateibeobachtung in Docker `compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg
- `LOG_LEVEL` steuert für beide Dienste die Ausgabestufe des strukturierten - `LOG_LEVEL` steuert für beide Dienste die Ausgabestufe des strukturierten
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
Setzbar über eine `.env`-Datei neben `compose.yaml` oder Setzbar über eine `.env`-Datei neben `compose.yaml` oder
`LOG_LEVEL=verbose docker compose up`. `LOG_LEVEL=verbose docker compose up`.
Beim API-Start laufen zuerst `npm run db:migrate` und Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung
`npm run db:verify:circuit-schema`. (`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 ## Logging

View file

@ -13,6 +13,9 @@
"docker:up": "docker compose up --build --detach", "docker:up": "docker compose up --build --detach",
"docker:down": "docker compose down", "docker:down": "docker compose down",
"docker:logs": "docker compose logs --follow", "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": "npm run build:api",
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",

View file

@ -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 });
}

View file

@ -5,8 +5,9 @@ import { createLogger } from "./shared/logging/logger";
const logger = createLogger("web:navigation"); const logger = createLogger("web:navigation");
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
// The Docker healthcheck hits "/" every few seconds with no User-Agent // Probes hit "/web-health" and are excluded by the matcher below. The
// header; skip it so real navigation isn't drowned out in the logs. // 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")) { if (request.headers.get("user-agent")) {
logger.info("page request", { logger.info("page request", {
method: request.method, method: request.method,
@ -17,5 +18,7 @@ export function proxy(request: NextRequest) {
} }
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"], matcher: [
"/((?!_next/static|_next/image|favicon.ico|api|web-health).*)",
],
}; };