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>
8.9 KiB
Executable file
Deployment und Betrieb
Aktueller Status
Es gibt zwei getrennte Compose-Dateien. Sie dürfen nicht verwechselt werden.
| Datei | Projektname | Zweck |
|---|---|---|
compose.yaml |
leistungsbilanz-dev |
ausschließlich lokale Entwicklung |
compose.prod.yaml |
leistungsbilanz |
Dauerbetrieb im LAN |
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).
Seine Ports sind deshalb an 127.0.0.1 gebunden.
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:
PORT– API-Port, Standard3000API_INTERNAL_URL– internes API-Ziel des Next.js-Rewrites, im Compose-Netzhttp://api:3000NEXT_TELEMETRY_DISABLED=1NODE_ENV=production– nur incompose.prod.yamlLOG_LEVEL– steuert für beide Dienste die Ausgabestufe des strukturierten JSON-Loggers (error,warn,info,verbose,debug), Standardinfo. Setzbar über eine.env-Datei nebencompose.yamloderLOG_LEVEL=verbose docker compose up.
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:
- Polling-Dateibeobachter.
CHOKIDAR_USEPOLLINGundWATCHPACK_POLLINGließentsx watchpermanent den Quellbaum abklappern. Beide Variablen sind entfernt; unter Linux funktioniertinotifyauf Bind-Mounts. - Healthcheck als Seitenrendering. Der Web-Healthcheck rief
/alle fünf Sekunden auf./ist einredirect("/projects"), und Node'sfetchfolgt Redirects – jede Prüfung rendert also die vollständige Projektliste, rund 16.000-mal pro Tag. Der Healthcheck zeigt jetzt auf/healthund läuft alle 30 Sekunden. - Kein Speicherlimit.
next devhält Kompilierungs-State und wächst unter Dauerlast unbegrenzt. Beide Compose-Dateien setzen jetztmem_limit; in der Produktion zusätzlichmemswap_limitin gleicher Höhe, damit ein Leck den Container beendet statt den Host in den Swap zu ziehen. - Kollidierender Projektname.
compose.yamlhießleistungsbilanzund damit genauso wie das Produktionsprojekt; eindocker compose downim Entwicklungsverzeichnis zielte auf den Produktionsstack. Der Entwicklungsname ist jetztleistungsbilanz-dev.
Produktionsdeployment
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:
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
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.
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.
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:
- TLS-Termination und Reverse Proxy
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
- definierte Secrets- und Konfigurationsverwaltung
- 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
Solange Authentifizierung fehlt, gehört der Stack ausschließlich in ein vertrauenswürdiges Netz oder hinter einen authentifizierenden Reverse Proxy.
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.