From 01fa527b9c8d93d6765b8fb701ab298cd9c7223a Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Sat, 15 Aug 2026 00:26:56 +0200 Subject: [PATCH] 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 --- .gitignore | 2 + AGENTS.md | 33 +++++++++- Dockerfile | 51 ++++++++++++--- README.md | 23 +++++-- compose.prod.yaml | 102 ++++++++++++++++++++++++++++++ compose.yaml | 70 ++++++++++++--------- docs/deployment.md | 135 +++++++++++++++++++++++++++++++++------- drizzle.config.js | 11 ---- index.html | 17 ----- scripts/docker-start.sh | 30 ++++++++- src/db/client.ts | 8 +++ src/proxy.ts | 11 ++-- src/server/index.ts | 34 +++++++++- styles.css | 15 ----- 14 files changed, 424 insertions(+), 118 deletions(-) mode change 100644 => 100755 .gitignore mode change 100644 => 100755 AGENTS.md mode change 100644 => 100755 Dockerfile mode change 100644 => 100755 README.md create mode 100755 compose.prod.yaml mode change 100644 => 100755 compose.yaml mode change 100644 => 100755 docs/deployment.md delete mode 100644 drizzle.config.js delete mode 100644 index.html mode change 100644 => 100755 scripts/docker-start.sh mode change 100644 => 100755 src/db/client.ts mode change 100644 => 100755 src/proxy.ts mode change 100644 => 100755 src/server/index.ts delete mode 100644 styles.css diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 8d44fb4..19da578 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ dist/ .next/ +# Stray tsc output; drizzle-kit reads drizzle.config.ts. +/drizzle.config.js data/*.db data/backups/*.db .codex/*.log diff --git a/AGENTS.md b/AGENTS.md old mode 100644 new mode 100755 index f1a509d..d781e10 --- a/AGENTS.md +++ b/AGENTS.md @@ -531,13 +531,44 @@ Users must be able to override sizing suggestions. - Preserve stable UUIDs and explicit transaction boundaries for a later PostgreSQL adapter. +## Deployment Rules + +There are two compose files and they must stay separate. + +- `compose.yaml` is development only. Project name `leistungsbilanz-dev`, ports + bound to `127.0.0.1`, watch-mode servers, source bind mounts. +- `compose.prod.yaml` is the deployment. Project name `leistungsbilanz`, image + target `prod`, compiled output, no bind mounts, no file watchers. + +Rules that a running deployment depends on: + +- Never run the development stack permanently. `next dev` and `tsx watch` + produce load and grow in memory with no user activity. +- Never reintroduce `CHOKIDAR_USEPOLLING` or `WATCHPACK_POLLING`. They make the + watcher poll the source tree continuously. +- Container healthchecks target `/health` only. `/` is a redirect to + `/projects`, and `fetch` follows redirects, so probing `/` renders a full + page on every interval. +- Every service keeps a `mem_limit`; production services also keep a + `memswap_limit` of the same size so a leak kills the container instead of the + host. +- The production image runs as `node`. Data volumes written by an earlier root + container need a one-time `chown` (see `docs/deployment.md`). +- The Express API is not published on the host. It has no authentication and is + reached through the Next.js rewrite. +- Development migrates with `drizzle-kit`; the production image has no + devDependencies and migrates with `node scripts/run-migrations.js`. +- `SIGTERM`/`SIGINT` must close the HTTP server and the SQLite handle and then + exit. Do not reduce these handlers to logging. + ## Current Deferred Work - Revit/CSV/IFCGUID round-trip, except when Phase 14 is explicitly requested and `docs/spec/revit-csv-integration-requirements.md` is being followed - full electrical sizing - multi-user/PostgreSQL operation -- supported production deployment +- authentication, authorization and TLS termination; until they exist the + deployment belongs in a trusted network or behind an authenticating proxy Do not implement these while working on an unrelated phase. diff --git a/Dockerfile b/Dockerfile old mode 100644 new mode 100755 index 41a08fd..4b4ed55 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,51 @@ -FROM node:22 -WORKDIR /app +# syntax=docker/dockerfile:1 +# --------------------------------------------------------------------------- +# deps: complete dependency tree, shared by the build and the dev target. +# Stays on the full node image because better-sqlite3 needs a toolchain. +# --------------------------------------------------------------------------- +FROM node:22 AS deps +WORKDIR /app COPY package*.json ./ RUN npm ci +# --------------------------------------------------------------------------- +# dev: watch-mode target used by compose.yaml. Keeps devDependencies so +# tsx, drizzle-kit and next dev are available. Sources come from bind mounts. +# --------------------------------------------------------------------------- +FROM deps AS dev +ENV NODE_ENV=development +COPY . . +RUN mkdir -p data +EXPOSE 3000 3001 +CMD ["npm", "run", "dev:api"] + +# --------------------------------------------------------------------------- +# build: compile the API to dist/ and the frontend to .next/, then drop +# devDependencies so the runtime stage carries only what it executes. +# --------------------------------------------------------------------------- +FROM deps AS build +ENV NEXT_TELEMETRY_DISABLED=1 +# Next.js resolves next.config.mjs rewrites at build time and writes the literal +# destination into .next/routes-manifest.json. Setting API_INTERNAL_URL only at +# runtime has no effect on `next start`, so the API address of the target +# topology has to be known here. Server components read the variable at runtime. +ARG API_INTERNAL_URL=http://localhost:3000 +ENV API_INTERNAL_URL=$API_INTERNAL_URL COPY . . RUN npm run build:api && npm run build:web +RUN npm prune --omit=dev -RUN mkdir -p data && chmod +x scripts/docker-start.sh - -EXPOSE 3001 - -CMD ["sh", "scripts/docker-start.sh"] +# --------------------------------------------------------------------------- +# prod: runtime image. node:22-slim shares the Debian release of the build +# stage, so the compiled better-sqlite3 binding stays loadable. +# --------------------------------------------------------------------------- +FROM node:22-slim AS prod +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +WORKDIR /app +COPY --from=build --chown=node:node /app ./ +RUN mkdir -p data && chown node:node data +USER node +EXPOSE 3000 3001 +CMD ["node", "dist/server/index.js"] diff --git a/README.md b/README.md old mode 100644 new mode 100755 index c333917..db895e6 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung. Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise, Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt. -Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb -mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment, +Das Projekt befindet sich in aktiver Entwicklung. Unterstützt sind der lokale +Entwicklungsbetrieb (`compose.yaml`) und ein Einzelbenutzer-Deployment im +vertrauenswürdigen Netz (`compose.prod.yaml`). Authentifizierung, Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht implementiert. @@ -62,9 +63,21 @@ docker compose logs --follow docker compose down ``` -Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein -Produktionsdeployment. Details stehen in -[Deployment und Betrieb](docs/deployment.md). +`compose.yaml` startet Entwicklungsserver mit Quellcode-Mounts, veröffentlicht +seine Ports nur auf `127.0.0.1` und ist **kein Produktionsdeployment**. Es +gehört nicht in den Dauerbetrieb: `next dev` und `tsx watch` erzeugen auch ohne +Benutzer dauerhaft Last und wachsen im Speicher. + +## Deployment + +```bash +docker compose -f compose.prod.yaml up --build --detach +``` + +Startet die kompilierte API und `next start` in getrennten Containern, mit +Speicherlimits und ohne Dateibeobachter. Das Frontend hört auf Port 3090, die +API ist nur intern erreichbar. Vorbereitung eines bestehenden Datenvolumes und +weitere Details stehen in [Deployment und Betrieb](docs/deployment.md). ## Direkte lokale Entwicklung diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100755 index 0000000..a7a56f3 --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,102 @@ +# Production deployment. +# +# Differences to compose.yaml that matter operationally: +# - runs compiled output (node dist/, next start) instead of watch mode +# - no source bind mounts and no file watchers, so an idle stack costs ~0% CPU +# - healthchecks hit the cheap /health JSON endpoint, not a rendered page +# - every service has a hard memory limit and may not spill into host swap +# - the Express API is reachable only inside the compose network +# +# Usage: +# docker compose -f compose.prod.yaml up --build --detach +name: leistungsbilanz + +x-service-defaults: &service-defaults + init: true + restart: unless-stopped + stop_grace_period: 20s + logging: + driver: json-file + options: + max-size: "20m" + max-file: "10" + +services: + api: + <<: *service-defaults + build: + context: . + target: prod + args: + # Baked into the Next.js rewrite manifest; see the build stage in the + # Dockerfile. Must match the API service address in this network. + API_INTERNAL_URL: http://api:3000 + command: + - sh + - -c + - node scripts/run-migrations.js && exec node dist/server/index.js + environment: + NODE_ENV: production + PORT: "3000" + LOG_LEVEL: "${LOG_LEVEL:-info}" + # Not published on the host: the API has no authentication and is reached + # through the Next.js rewrite in the web service. + expose: + - "3000" + mem_limit: 512m + memswap_limit: 512m + volumes: + - leistungsbilanz-data:/app/data + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1:3000/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1)) + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + web: + <<: *service-defaults + build: + context: . + target: prod + args: + # Baked into the Next.js rewrite manifest; see the build stage in the + # Dockerfile. Must match the API service address in this network. + API_INTERNAL_URL: http://api:3000 + command: + - node_modules/.bin/next + - start + - -p + - "3001" + - --hostname + - 0.0.0.0 + environment: + NODE_ENV: production + API_INTERNAL_URL: http://api:3000 + LOG_LEVEL: "${LOG_LEVEL:-info}" + depends_on: + api: + condition: service_healthy + ports: + - "3090:3001" + mem_limit: 1g + memswap_limit: 1g + healthcheck: + # /health is rewritten to the API, so this verifies the web process and + # its API connectivity for the cost of one JSON response. + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1:3001/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1)) + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + +volumes: + leistungsbilanz-data: diff --git a/compose.yaml b/compose.yaml old mode 100644 new mode 100755 index 6a5ec7c..5d25db7 --- a/compose.yaml +++ b/compose.yaml @@ -1,26 +1,39 @@ -name: leistungsbilanz +# Local development only. Runs watch-mode servers against bind-mounted sources. +# For a deployment use compose.prod.yaml. +# +# The project name must stay distinct from the production project so that +# `docker compose down` in this directory can never tear down a running +# production stack. +name: leistungsbilanz-dev + +x-service-defaults: &service-defaults + init: true + restart: unless-stopped + stop_grace_period: 20s + logging: + driver: json-file + options: + max-size: "20m" + max-file: "10" services: api: + <<: *service-defaults build: context: . + target: dev 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:-info}" - init: true - restart: unless-stopped - logging: - driver: json-file - options: - max-size: "20m" - max-file: "10" + # Development ports stay on the loopback interface; nothing here is + # authenticated and the API must not be reachable from the LAN. ports: - - "3000:3000" + - "127.0.0.1:3000:3000" + mem_limit: 1g volumes: - ./src:/app/src - ./scripts:/app/scripts @@ -32,15 +45,17 @@ services: - CMD - node - -e - - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 5s - timeout: 3s - retries: 12 - start_period: 20s + - fetch('http://127.0.0.1:3000/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1)) + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s web: + <<: *service-defaults build: context: . + target: dev command: - npm - run @@ -50,33 +65,30 @@ services: - 0.0.0.0 environment: API_INTERNAL_URL: http://api:3000 - WATCHPACK_POLLING: "true" NEXT_TELEMETRY_DISABLED: "1" LOG_LEVEL: "${LOG_LEVEL:-info}" - init: true - restart: unless-stopped - logging: - driver: json-file - options: - max-size: "20m" - max-file: "10" depends_on: api: condition: service_healthy ports: - - "3001:3001" + - "127.0.0.1:3001:3001" + # next dev grows steadily under long-running use; the limit turns that into + # a container restart instead of host-wide swapping. + mem_limit: 2g 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: + # Must stay on /health: "/" redirects to /projects and Node's fetch + # follows redirects, which turned every probe into a full page render. test: - CMD - node - -e - - fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 5s - timeout: 3s - retries: 12 - start_period: 20s + - fetch('http://127.0.0.1:3001/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1)) + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s diff --git a/docs/deployment.md b/docs/deployment.md old mode 100644 new mode 100755 index dfbb3f1..355cf40 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 diff --git a/drizzle.config.js b/drizzle.config.js deleted file mode 100644 index 004045a..0000000 --- a/drizzle.config.js +++ /dev/null @@ -1,11 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const drizzle_kit_1 = require("drizzle-kit"); -exports.default = (0, drizzle_kit_1.defineConfig)({ - dialect: "sqlite", - schema: "./src/db/schema/*.ts", - out: "./src/db/migrations", - dbCredentials: { - url: "./data/leistungsbilanz.db", - }, -}); diff --git a/index.html b/index.html deleted file mode 100644 index dcb632f..0000000 --- a/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - Leistungsbilanz API - - - -
-

Leistungsbilanz Backend

-

Der aktuelle Stand laeuft als TypeScript Node/SQLite API.

-

Serverstart: npm run dev

-

Healthcheck: GET /health

-
- - diff --git a/scripts/docker-start.sh b/scripts/docker-start.sh old mode 100644 new mode 100755 index bf0d856..85a27a6 --- a/scripts/docker-start.sh +++ b/scripts/docker-start.sh @@ -1,4 +1,7 @@ #!/bin/sh +# Legacy single-container entrypoint. Kept for the existing deployment that +# still runs both processes in one container; new deployments use +# compose.prod.yaml, which runs the API and the web server separately. set -e echo "Running migrations..." @@ -6,11 +9,34 @@ node scripts/run-migrations.js echo "Starting API server on :3000..." node dist/server/index.js & +api_pid=$! echo "Waiting for API..." -until node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" 2>/dev/null; do +until node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 2>/dev/null; do + if ! kill -0 "$api_pid" 2>/dev/null; then + echo "API process exited during startup." + exit 1 + fi sleep 1 done echo "Starting Next.js on :3001..." -exec node_modules/.bin/next start -p 3001 +node_modules/.bin/next start -p 3001 & +web_pid=$! + +terminate() { + kill -TERM "$api_pid" "$web_pid" 2>/dev/null || true +} +trap terminate TERM INT + +# Previously the API ran unsupervised in the background: when it died the +# container stayed "up" and served a frontend whose every request failed. +# Exiting here lets the restart policy replace the container instead. +while kill -0 "$api_pid" 2>/dev/null && kill -0 "$web_pid" 2>/dev/null; do + sleep 5 +done + +echo "A server process exited; stopping the container." +terminate +wait "$api_pid" "$web_pid" 2>/dev/null || true +exit 1 diff --git a/src/db/client.ts b/src/db/client.ts old mode 100644 new mode 100755 index 02cfc7e..39f4125 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -7,3 +7,11 @@ const defaultDatabaseContext = createDatabaseContext( export const db = defaultDatabaseContext.db; +/** + * Closes the process-wide SQLite handle. Only the composition root calls this, + * during shutdown, so pending writes are flushed before the process exits. + */ +export function closeDefaultDatabase(): void { + defaultDatabaseContext.close(); +} + diff --git a/src/proxy.ts b/src/proxy.ts old mode 100644 new mode 100755 index 4607ec0..a2cb084 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -4,10 +4,13 @@ import { createLogger } from "./shared/logging/logger"; const logger = createLogger("web:navigation"); +// Node's fetch() sends "User-Agent: node", so the previous "no User-Agent" +// check never matched and every healthcheck was logged as page navigation. +const PROBE_USER_AGENT = /^(node|curl|wget|go-http-client|kube-probe)\b/i; + export function proxy(request: NextRequest) { - // The Docker healthcheck hits "/" every few seconds with no User-Agent - // header; skip it so real navigation isn't drowned out in the logs. - if (request.headers.get("user-agent")) { + const userAgent = request.headers.get("user-agent") ?? ""; + if (userAgent && !PROBE_USER_AGENT.test(userAgent)) { logger.info("page request", { method: request.method, path: request.nextUrl.pathname, @@ -17,5 +20,5 @@ export function proxy(request: NextRequest) { } export const config = { - matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"], + matcher: ["/((?!_next/static|_next/image|favicon.ico|api|health).*)"], }; diff --git a/src/server/index.ts b/src/server/index.ts old mode 100644 new mode 100755 index 2f6ac5e..3ee21f1 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,6 +4,7 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js"; import { projectRouter } from "./routes/project.routes.js"; import { errorMiddleware } from "./middleware/error.middleware.js"; import { createLogger, toErrorMeta } from "../shared/logging/logger.js"; +import { closeDefaultDatabase } from "../db/client.js"; const logger = createLogger("api"); const app = express(); @@ -62,8 +63,35 @@ process.on("unhandledRejection", (reason) => { process.exit(1); }); -process.on("SIGTERM", () => logger.info("received SIGTERM")); -process.on("SIGINT", () => logger.info("received SIGINT")); +let shuttingDown = false; + +function shutdown(signal: NodeJS.Signals): void { + if (shuttingDown) return; + shuttingDown = true; + logger.info("shutting down", { signal }); + + // Without this the process only logged the signal and kept running, so every + // stop waited for Docker's grace period and ended in SIGKILL mid-write. + const forceExit = setTimeout(() => { + logger.warn("shutdown timed out, exiting anyway"); + process.exit(1); + }, 15_000); + forceExit.unref(); + + server.close((error) => { + if (error) logger.error("http server close failed", toErrorMeta(error)); + try { + closeDefaultDatabase(); + } catch (closeError) { + logger.error("database close failed", toErrorMeta(closeError)); + } + logger.info("shutdown complete"); + process.exit(error ? 1 : 0); + }); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); setInterval(() => { const memory = process.memoryUsage(); @@ -74,6 +102,6 @@ setInterval(() => { }); }, heartbeatIntervalMs).unref(); -app.listen(port, () => { +const server = app.listen(port, () => { logger.info("server started", { port }); }); diff --git a/styles.css b/styles.css deleted file mode 100644 index 58c8f79..0000000 --- a/styles.css +++ /dev/null @@ -1,15 +0,0 @@ -body { - margin: 0; - font-family: Arial, sans-serif; - background: #f5f6f8; - color: #1f2937; -} - -.info { - max-width: 760px; - margin: 40px auto; - background: #ffffff; - border: 1px solid #d1d5db; - border-radius: 8px; - padding: 24px; -}