Add production compose stack and stop idle load in containers

The development stack was running permanently on a server: polling file
watchers, a healthcheck that rendered a full page every five seconds and no
memory limit grew next dev to 10 GB and pushed the host into swap.

- add compose.prod.yaml running compiled output in separate api/web services
- make the Dockerfile multi-stage with dev and prod targets, prune
  devDependencies and run the runtime image as node instead of root
- bake API_INTERNAL_URL at build time; next start ignores it at runtime
  because rewrite destinations are resolved into routes-manifest.json
- drop CHOKIDAR_USEPOLLING and WATCHPACK_POLLING
- probe /health instead of /, which redirects to /projects and made every
  healthcheck render the project list
- give every service a memory limit and forbid swap in production
- rename the development compose project to leistungsbilanz-dev so its
  down command cannot target the production stack
- bind development ports to localhost
- close the http server and the SQLite handle on SIGTERM/SIGINT
- match probe user agents in the navigation log filter; Node's fetch sends
  one, so the previous check never matched
- exit docker-start.sh when either supervised process dies
- remove drizzle.config.js, a compiled copy drizzle-kit never reads, and the
  pre-Next index.html/styles.css leftovers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-08-15 00:26:56 +02:00
parent a17e2e3f4b
commit 01fa527b9c
14 changed files with 424 additions and 118 deletions

135
docs/deployment.md Normal file → Executable file
View file

@ -2,21 +2,40 @@
## Aktueller Status
Es gibt derzeit kein unterstütztes Produktionsdeployment.
Es gibt zwei getrennte Compose-Dateien. Sie dürfen nicht verwechselt werden.
`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.
| Datei | Projektname | Zweck |
| --- | --- | --- |
| `compose.yaml` | `leistungsbilanz-dev` | ausschließlich lokale Entwicklung |
| `compose.prod.yaml` | `leistungsbilanz` | Dauerbetrieb im LAN |
## Entwicklungs-Topologie
`compose.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom Host
ein. Dieser Stack ist **nicht** für Dauerbetrieb geeignet (siehe
[Warum kein Dev-Stack im Dauerbetrieb](#warum-kein-dev-stack-im-dauerbetrieb)).
Seine Ports sind deshalb an `127.0.0.1` gebunden.
| Komponente | Port | Healthcheck | Persistenz |
| --- | ---: | --- | --- |
| Next.js Web | 3001 | `GET /` | keine |
| Express API | 3000 | `GET /health` | `./data:/app/data` |
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
`compose.prod.yaml` startet kompilierten Code ohne Dateibeobachter. Es enthält
weiterhin weder TLS, Authentifizierung noch ein Benutzer-/Rollenmodell; die
Express-API wird deshalb nicht auf dem Host veröffentlicht, sondern nur über den
Next.js-Rewrite erreicht. Der Stack gehört hinter einen Reverse Proxy mit
Authentifizierung und darf nicht öffentlich erreichbar gemacht werden.
## Topologie
Entwicklung (`compose.yaml`):
| Komponente | Port | Healthcheck | Speicherlimit | Persistenz |
| --- | ---: | --- | ---: | --- |
| Next.js Web | `127.0.0.1:3001` | `GET /health` alle 30 s | 2 GB | keine |
| Express API | `127.0.0.1:3000` | `GET /health` alle 30 s | 1 GB | `./data:/app/data` |
Produktion (`compose.prod.yaml`):
| Komponente | Port | Healthcheck | Speicherlimit | Persistenz |
| --- | ---: | --- | ---: | --- |
| Next.js Web | `3090` | `GET /health` alle 30 s | 1 GB, kein Swap | keine |
| Express API | nur intern | `GET /health` alle 30 s | 512 MB, kein Swap | Volume `leistungsbilanz-data` |
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | | `data/leistungsbilanz.db` |
Verwendete Umgebungsvariablen:
@ -24,15 +43,66 @@ 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
- `NODE_ENV=production` nur in `compose.prod.yaml`
- `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 in der Entwicklung zuerst `npm run db:migrate` und
`npm run db:verify:circuit-schema` (drizzle-kit, eine devDependency). Das
Produktionsimage enthält keine devDependencies und migriert stattdessen über
`node scripts/run-migrations.js`, das denselben Migrationsordner mit
`drizzle-orm` anwendet.
## Warum kein Dev-Stack im Dauerbetrieb
`compose.yaml` lief einmal fünf Tage durchgehend auf einem Server. Ergebnis:
10,3 GB belegter Arbeitsspeicher im Web-Container, 12,2 % Dauer-CPU im
API-Container und ein Host, der 13 GB Swap belegt hatte. Vier Ursachen wirkten
zusammen; alle vier sind inzwischen behoben:
1. **Polling-Dateibeobachter.** `CHOKIDAR_USEPOLLING` und `WATCHPACK_POLLING`
ließen `tsx watch` permanent den Quellbaum abklappern. Beide Variablen sind
entfernt; unter Linux funktioniert `inotify` auf Bind-Mounts.
2. **Healthcheck als Seitenrendering.** Der Web-Healthcheck rief `/` alle fünf
Sekunden auf. `/` ist ein `redirect("/projects")`, und Node's `fetch` folgt
Redirects jede Prüfung rendert also die vollständige Projektliste, rund
16.000-mal pro Tag. Der Healthcheck zeigt jetzt auf `/health` und läuft alle
30 Sekunden.
3. **Kein Speicherlimit.** `next dev` hält Kompilierungs-State und wächst unter
Dauerlast unbegrenzt. Beide Compose-Dateien setzen jetzt `mem_limit`; in der
Produktion zusätzlich `memswap_limit` in gleicher Höhe, damit ein Leck den
Container beendet statt den Host in den Swap zu ziehen.
4. **Kollidierender Projektname.** `compose.yaml` hieß `leistungsbilanz` und
damit genauso wie das Produktionsprojekt; ein `docker compose down` im
Entwicklungsverzeichnis zielte auf den Produktionsstack. Der Entwicklungsname
ist jetzt `leistungsbilanz-dev`.
## Produktionsdeployment
```bash
docker compose -f compose.prod.yaml up --build --detach
docker compose -f compose.prod.yaml ps
docker compose -f compose.prod.yaml logs --follow
```
Das Frontend hört danach auf Port 3090. Die API ist nur innerhalb des
Compose-Netzes erreichbar.
Das Produktionsimage läuft als Benutzer `node` statt als `root`. Wenn ein
bestehendes Datenvolume von einem früheren root-Container beschrieben wurde,
müssen dessen Dateien einmalig übereignet werden, sonst schlagen Schreibzugriffe
mit `SQLITE_READONLY` fehl:
```bash
docker run --rm -v leistungsbilanz_leistungsbilanz-data:/data alpine \
chown -R 1000:1000 /data
```
Ein bestehendes Ein-Container-Deployment über `scripts/docker-start.sh` wird
abgelöst, indem dessen Stack gestoppt und `compose.prod.yaml` mit demselben
Projektnamen `leistungsbilanz` gestartet wird; das Volume bleibt dabei erhalten.
## Logging
@ -68,23 +138,40 @@ einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit
`restart: unless-stopped`, damit Docker sie danach automatisch neu startet;
ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen.
## Voraussetzungen für ein späteres Produktionssetup
Anfragen von Healthcheck- und Monitoring-Clients (`node`, `curl`, `wget`, …)
werden anhand des User-Agents aus dem Navigationslog gefiltert und `/health`
läuft gar nicht erst durch `src/proxy.ts`.
Vor einer produktiven Installation werden mindestens benötigt:
## Herunterfahren
`SIGTERM` und `SIGINT` schließen den HTTP-Server, danach das SQLite-Handle, und
beenden den Prozess mit Code 0. Kommt der Server binnen 15 Sekunden nicht
herunter, beendet sich der Prozess trotzdem. Vorher wurde das Signal nur
geloggt: jeder `docker stop` lief in die Grace Period und endete mit `SIGKILL`,
möglicherweise mitten in einem Schreibvorgang. `stop_grace_period` steht in
beiden Compose-Dateien auf 20 s und liegt damit über dem internen Timeout.
## Offene Punkte für einen vollwertigen Produktionsbetrieb
Umgesetzt:
- reproduzierbares Produktionsimage und Next.js-Produktionsstart
- kontrollierter Migrationsschritt vor dem API-Start
- strukturierte Logs mit Rotation, Healthchecks, Speicherlimits
- definiertes Herunterfahren
Weiterhin offen:
- reproduzierbare Produktionsimages und ein Next.js-Produktionsstartskript
- TLS-Termination und Reverse Proxy
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
- definierte Secrets- und Konfigurationsverwaltung
- persistenter, gesicherter Datenbankbetrieb
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout
- Monitoring, strukturierte Logs und Alarmierung
- Alarmierung auf Basis der Healthchecks
- getestete Backup-, Restore- und Rollback-Prozeduren
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
Mehrbenutzerbetrieb erforderlich ist
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem
lokalen Entwicklungsstart in der README.
Solange Authentifizierung fehlt, gehört der Stack ausschließlich in ein
vertrauenswürdiges Netz oder hinter einen authentifizierenden Reverse Proxy.
## Backup und Wiederherstellung