leistungsbilanz-ts/docs/deployment.md
Julian Appel 2e3e85a8e5 Harden the container runtime and shut down cleanly
Running the stack on built output stopped the idle CPU load, but the
remaining findings from the deployment report were still open.

The Dockerfile is now multi-stage. The prod target runs on node:24-slim as
the "node" user instead of root and carries only its runtime dependencies;
"npm prune --omit=dev" drops tsx, drizzle-kit and typescript. The migration
scripts need drizzle-orm and better-sqlite3, both regular dependencies, so
they keep working. The dev target keeps devDependencies for compose.dev.yaml.

Both compose files get a mem_limit. The server stack also sets memswap_limit
to the same value, so a leak lets the OOM killer replace the container
instead of pushing the host into swap. The Express API is no longer
published on the host: it has no authentication and is reached through the
Next.js rewrite. Development ports are bound to the loopback interface.
Services drop all capabilities and run with no-new-privileges.

SIGTERM and SIGINT now close the HTTP server and the SQLite handle and exit,
instead of only logging the signal and waiting for the container's SIGKILL
ten seconds later. server.closeIdleConnections() releases the keep-alive
sockets the Next.js server holds, which would otherwise keep server.close()
pending for the whole grace period. The compose start commands exec into
node, because otherwise sh stays the foreground process and the signal never
reaches it.

Delete scripts/docker-start.sh rather than fixing its unsupervised
background API: both compose stacks set their own command, so the
single-container entrypoint has no caller left. Also remove drizzle.config.js
(a stray tsc artifact drizzle-kit never reads) and the pre-Next index.html
and styles.css.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:04:34 +02:00

7.7 KiB
Raw Blame History

Deployment und Betrieb

Aktueller Status

Es gibt zwei Compose-Stacks.

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. Beide Dienste laufen aus der prod-Stufe des Dockerfiles, also als Benutzer node statt als root, ohne devDependencies, mit cap_drop: ALL, no-new-privileges und harten Speichergrenzen.

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 Auf dem Host Healthcheck Persistenz
Next.js Web 3001 3001 (Server) / 127.0.0.1:3001 (Dev) GET /web-health keine
Express API 3000 nicht veröffentlicht (Server) / 127.0.0.1:3000 (Dev) GET /health ./data:/app/data
SQLite Datei – Integritäts-/FK-Prüfung via Backup und Skript data/leistungsbilanz.db

Die Express-API wird im Server-Stack nicht mehr auf dem Host veröffentlicht (expose statt ports). Sie hat keine Authentifizierung; jeder, der sie erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar.

Speichergrenzen

Jeder Dienst hat ein mem_limit. Im Server-Stack ist zusätzlich memswap_limit auf denselben Wert gesetzt, womit Swap für den Container abgeschaltet ist: ein Speicherleck lässt dann den Container am OOM-Killer sterben und restart: unless-stopped greift, statt dass der Host in den Swap läuft. Aktuell 512 MB für die API und 1 GB für das Frontend. Der Entwicklungs-Stack bekommt 1 GB bzw. 2 GB und behält Swap, weil next dev Kompilierzustand hält und im Dauerbetrieb stetig wächst.

Rechte im Container

Das Laufzeit-Image läuft als node (UID 1000). Ein data/-Verzeichnis, das ein früherer root-Container angelegt hat, ist dann nicht beschreibbar und braucht einmalig auf dem Host:

sudo chown -R 1000:1000 data

Verwendete Umgebungsvariablen:

  • PORT – API-Port, Standard 3000
  • 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 – 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 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

Beide Dienste schreiben strukturierte, einzeilige JSON-Log-Zeilen nach stdout/stderr (docker compose logs --follow). Jede Zeile enthält timestamp, level, scope und message. compose.yaml konfiguriert für beide Dienste den json-file-Treiber mit Rotation (max-size: 20m, max-file: 10, also bis zu 200 MB je Dienst); ohne diese Einstellung würde Docker mit der Standardkonfiguration unbegrenzt in eine einzelne Datei unter /var/lib/docker/containers/<container-id>/ schreiben. Die Logs überleben einen Container-Neustart (docker compose restart), aber nicht das Entfernen des Containers (docker compose down gefolgt von up erzeugt neue Container und damit neue, leere Logdateien); für ein echtes Langzeitarchiv über Rebuilds hinweg müssten die Zeilen zusätzlich in eine Datei im gemounteten ./data-Verzeichnis oder an ein externes Log-System geschrieben werden. Die Express-API protokolliert jede abgeschlossene Anfrage (Methode, Pfad, Status, Dauer; /health wird nicht mitgeloggt) sowie unbehandelte Exceptions/Promise-Rejections. Das Next.js-Frontend protokolliert Seitenanfragen (Navigation) über src/proxy.ts und unbehandelte Fehler über src/instrumentation.ts. Beide Prozesse schreiben zusätzlich alle fünf Minuten einen verbose-Heartbeat mit Laufzeit und Speicherverbrauch – nützlich, um Speicherlecks oder Hänger vor einem 502 über einen längeren Zeitraum nachzuvollziehen. Für die Detailsuche LOG_LEVEL=debug setzen; das protokolliert zusätzlich den Start jeder API-Anfrage und macht damit hängende (nie abgeschlossene) Requests sichtbar. Ein close-Ereignis ohne vorheriges finish wird als request aborted before response finished (warn) geloggt und zeigt damit vom Client oder einem vorgeschalteten Proxy abgebrochene Verbindungen.

Eine unbehandelte Exception oder Promise-Rejection wird geloggt und beendet den jeweiligen Prozess anschließend bewusst (process.exit(1)), statt in 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.

Herunterfahren

SIGTERM und SIGINT schließen in der API den HTTP-Server und anschließend das SQLite-Handle und beenden den Prozess. Offene Keep-Alive-Verbindungen – vor allem die des Next.js-Servers – werden dabei über server.closeIdleConnections() freigegeben, laufende Requests dürfen zu Ende laufen. Vorher wurde das Signal nur geloggt, der Prozess lief weiter und Docker beendete ihn nach zehn Sekunden mit SIGKILL, mitten in einem möglichen Schreibvorgang. Greift der Abbau nicht innerhalb von 15 Sekunden, beendet sich der Prozess selbst mit Exit-Code 1; stop_grace_period: 20s in beiden Compose-Dateien liegt bewusst darüber.

Die Startbefehle in compose.yaml enden mit exec node …. Ohne das exec bleibt sh der Vordergrundprozess, und SIGTERM erreicht Node nie.

Voraussetzungen für ein späteres Produktionssetup

Vor einer produktiven Installation werden mindestens benötigt:

  • 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
  • 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.

Backup und Wiederherstellung

npm run db:backup erzeugt unter data/backups/ ein konsistentes SQLite-Backup, öffnet es unabhängig und prüft Integrität sowie Fremdschlüssel. Diese Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt.

Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei gestoppter API, gegen eine separate Datei und nach erneuter Integritätsprüfung erfolgen.