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>
This commit is contained in:
Julian Appel 2026-09-16 11:04:34 +02:00
parent 204499d7e4
commit 2e3e85a8e5
13 changed files with 238 additions and 121 deletions

View file

@ -6,7 +6,9 @@ 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.
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
@ -20,11 +22,37 @@ erreichbar gemacht werden.
## Topologie
| Komponente | Port | Healthcheck | Persistenz |
| --- | ---: | --- | --- |
| 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` |
| 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:
```bash
sudo chown -R 1000:1000 data
```
Verwendete Umgebungsvariablen:
@ -83,11 +111,25 @@ 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:
- reproduzierbare Produktionsimages und ein Next.js-Produktionsstartskript
- TLS-Termination und Reverse Proxy
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
- definierte Secrets- und Konfigurationsverwaltung