Add production compose stack and stop idle load in containers #2

Open
Grovy311 wants to merge 1 commit from Grovy311/leistungsbilanz-ts:fix/production-deployment into main
14 changed files with 424 additions and 118 deletions
Showing only changes of commit 01fa527b9c - Show all commits

2
.gitignore vendored Normal file → Executable file
View file

@ -1,6 +1,8 @@
node_modules/ node_modules/
dist/ dist/
.next/ .next/
# Stray tsc output; drizzle-kit reads drizzle.config.ts.
/drizzle.config.js
data/*.db data/*.db
data/backups/*.db data/backups/*.db
.codex/*.log .codex/*.log

33
AGENTS.md Normal file → Executable file
View file

@ -531,13 +531,44 @@ Users must be able to override sizing suggestions.
- Preserve stable UUIDs and explicit transaction boundaries for a later - Preserve stable UUIDs and explicit transaction boundaries for a later
PostgreSQL adapter. 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 ## Current Deferred Work
- Revit/CSV/IFCGUID round-trip, except when Phase 14 is explicitly requested and - Revit/CSV/IFCGUID round-trip, except when Phase 14 is explicitly requested and
`docs/spec/revit-csv-integration-requirements.md` is being followed `docs/spec/revit-csv-integration-requirements.md` is being followed
- full electrical sizing - full electrical sizing
- multi-user/PostgreSQL operation - 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. Do not implement these while working on an unrelated phase.

51
Dockerfile Normal file → Executable file
View file

@ -1,14 +1,51 @@
FROM node:22 # syntax=docker/dockerfile:1
WORKDIR /app
# ---------------------------------------------------------------------------
# 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 ./ COPY package*.json ./
RUN npm ci 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 . . COPY . .
RUN npm run build:api && npm run build:web RUN npm run build:api && npm run build:web
RUN npm prune --omit=dev
RUN mkdir -p data && chmod +x scripts/docker-start.sh # ---------------------------------------------------------------------------
# prod: runtime image. node:22-slim shares the Debian release of the build
EXPOSE 3001 # stage, so the compiled better-sqlite3 binding stays loadable.
# ---------------------------------------------------------------------------
CMD ["sh", "scripts/docker-start.sh"] 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"]

23
README.md Normal file → Executable file
View file

@ -4,8 +4,9 @@ Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise, Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt. Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb Das Projekt befindet sich in aktiver Entwicklung. Unterstützt sind der lokale
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment, Entwicklungsbetrieb (`compose.yaml`) und ein Einzelbenutzer-Deployment im
vertrauenswürdigen Netz (`compose.prod.yaml`). Authentifizierung,
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
implementiert. implementiert.
@ -62,9 +63,21 @@ docker compose logs --follow
docker compose down docker compose down
``` ```
Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein `compose.yaml` startet Entwicklungsserver mit Quellcode-Mounts, veröffentlicht
Produktionsdeployment. Details stehen in seine Ports nur auf `127.0.0.1` und ist **kein Produktionsdeployment**. Es
[Deployment und Betrieb](docs/deployment.md). 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 ## Direkte lokale Entwicklung

102
compose.prod.yaml Executable file
View file

@ -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:

70
compose.yaml Normal file → Executable file
View file

@ -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: services:
api: api:
<<: *service-defaults
build: build:
context: . context: .
target: dev
command: command:
- sh - sh
- -c - -c
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
environment: environment:
PORT: "3000" PORT: "3000"
CHOKIDAR_USEPOLLING: "true"
LOG_LEVEL: "${LOG_LEVEL:-info}" LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true # Development ports stay on the loopback interface; nothing here is
restart: unless-stopped # authenticated and the API must not be reachable from the LAN.
logging:
driver: json-file
options:
max-size: "20m"
max-file: "10"
ports: ports:
- "3000:3000" - "127.0.0.1:3000:3000"
mem_limit: 1g
volumes: volumes:
- ./src:/app/src - ./src:/app/src
- ./scripts:/app/scripts - ./scripts:/app/scripts
@ -32,15 +45,17 @@ services:
- CMD - CMD
- node - node
- -e - -e
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - fetch('http://127.0.0.1:3000/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1))
interval: 5s interval: 30s
timeout: 3s timeout: 5s
retries: 12 retries: 5
start_period: 20s start_period: 30s
web: web:
<<: *service-defaults
build: build:
context: . context: .
target: dev
command: command:
- npm - npm
- run - run
@ -50,33 +65,30 @@ services:
- 0.0.0.0 - 0.0.0.0
environment: environment:
API_INTERNAL_URL: http://api:3000 API_INTERNAL_URL: http://api:3000
WATCHPACK_POLLING: "true"
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
LOG_LEVEL: "${LOG_LEVEL:-info}" LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
max-file: "10"
depends_on: depends_on:
api: api:
condition: service_healthy condition: service_healthy
ports: 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: volumes:
- ./src:/app/src - ./src:/app/src
- ./next.config.mjs:/app/next.config.mjs:ro - ./next.config.mjs:/app/next.config.mjs:ro
- ./tsconfig.json:/app/tsconfig.json:ro - ./tsconfig.json:/app/tsconfig.json:ro
- ./tsconfig.next.json:/app/tsconfig.next.json:ro - ./tsconfig.next.json:/app/tsconfig.next.json:ro
healthcheck: healthcheck:
# Must stay on /health: "/" redirects to /projects and Node's fetch
# follows redirects, which turned every probe into a full page render.
test: test:
- CMD - CMD
- node - node
- -e - -e
- fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - fetch('http://127.0.0.1:3001/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1))
interval: 5s interval: 30s
timeout: 3s timeout: 5s
retries: 12 retries: 5
start_period: 20s start_period: 30s

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

@ -2,21 +2,40 @@
## Aktueller Status ## 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 | Datei | Projektname | Zweck |
`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS, | --- | --- | --- |
Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes | `compose.yaml` | `leistungsbilanz-dev` | ausschließlich lokale Entwicklung |
Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder | `compose.prod.yaml` | `leistungsbilanz` | Dauerbetrieb im LAN |
öffentlich erreichbar gemacht werden.
## 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 | `compose.prod.yaml` startet kompilierten Code ohne Dateibeobachter. Es enthält
| --- | ---: | --- | --- | weiterhin weder TLS, Authentifizierung noch ein Benutzer-/Rollenmodell; die
| Next.js Web | 3001 | `GET /` | keine | Express-API wird deshalb nicht auf dem Host veröffentlicht, sondern nur über den
| Express API | 3000 | `GET /health` | `./data:/app/data` | Next.js-Rewrite erreicht. Der Stack gehört hinter einen Reverse Proxy mit
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | 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: Verwendete Umgebungsvariablen:
@ -24,15 +43,66 @@ Verwendete Umgebungsvariablen:
- `API_INTERNAL_URL` internes API-Ziel des Next.js-Rewrites, im Compose-Netz - `API_INTERNAL_URL` internes API-Ziel des Next.js-Rewrites, im Compose-Netz
`http://api:3000` `http://api:3000`
- `NEXT_TELEMETRY_DISABLED=1` - `NEXT_TELEMETRY_DISABLED=1`
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale - `NODE_ENV=production` nur in `compose.prod.yaml`
Dateibeobachtung in Docker
- `LOG_LEVEL` steuert für beide Dienste die Ausgabestufe des strukturierten - `LOG_LEVEL` steuert für beide Dienste die Ausgabestufe des strukturierten
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
Setzbar über eine `.env`-Datei neben `compose.yaml` oder Setzbar über eine `.env`-Datei neben `compose.yaml` oder
`LOG_LEVEL=verbose docker compose up`. `LOG_LEVEL=verbose docker compose up`.
Beim API-Start laufen zuerst `npm run db:migrate` und Beim API-Start laufen in der Entwicklung zuerst `npm run db:migrate` und
`npm run db:verify:circuit-schema`. `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 ## Logging
@ -68,23 +138,40 @@ einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit
`restart: unless-stopped`, damit Docker sie danach automatisch neu startet; `restart: unless-stopped`, damit Docker sie danach automatisch neu startet;
ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen. 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 - TLS-Termination und Reverse Proxy
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell - Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
- definierte Secrets- und Konfigurationsverwaltung - definierte Secrets- und Konfigurationsverwaltung
- persistenter, gesicherter Datenbankbetrieb - Alarmierung auf Basis der Healthchecks
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout
- Monitoring, strukturierte Logs und Alarmierung
- getestete Backup-, Restore- und Rollback-Prozeduren - getestete Backup-, Restore- und Rollback-Prozeduren
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für - Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
Mehrbenutzerbetrieb erforderlich ist Mehrbenutzerbetrieb erforderlich ist
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem Solange Authentifizierung fehlt, gehört der Stack ausschließlich in ein
lokalen Entwicklungsstart in der README. vertrauenswürdiges Netz oder hinter einen authentifizierenden Reverse Proxy.
## Backup und Wiederherstellung ## Backup und Wiederherstellung

View file

@ -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",
},
});

View file

@ -1,17 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leistungsbilanz API</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="info">
<h1>Leistungsbilanz Backend</h1>
<p>Der aktuelle Stand laeuft als TypeScript Node/SQLite API.</p>
<p>Serverstart: <code>npm run dev</code></p>
<p>Healthcheck: <code>GET /health</code></p>
</main>
</body>
</html>

30
scripts/docker-start.sh Normal file → Executable file
View file

@ -1,4 +1,7 @@
#!/bin/sh #!/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 set -e
echo "Running migrations..." echo "Running migrations..."
@ -6,11 +9,34 @@ node scripts/run-migrations.js
echo "Starting API server on :3000..." echo "Starting API server on :3000..."
node dist/server/index.js & node dist/server/index.js &
api_pid=$!
echo "Waiting for API..." 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 sleep 1
done done
echo "Starting Next.js on :3001..." 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

8
src/db/client.ts Normal file → Executable file
View file

@ -7,3 +7,11 @@ const defaultDatabaseContext = createDatabaseContext(
export const db = defaultDatabaseContext.db; 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();
}

11
src/proxy.ts Normal file → Executable file
View file

@ -4,10 +4,13 @@ import { createLogger } from "./shared/logging/logger";
const logger = createLogger("web:navigation"); 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) { export function proxy(request: NextRequest) {
// The Docker healthcheck hits "/" every few seconds with no User-Agent const userAgent = request.headers.get("user-agent") ?? "";
// header; skip it so real navigation isn't drowned out in the logs. if (userAgent && !PROBE_USER_AGENT.test(userAgent)) {
if (request.headers.get("user-agent")) {
logger.info("page request", { logger.info("page request", {
method: request.method, method: request.method,
path: request.nextUrl.pathname, path: request.nextUrl.pathname,
@ -17,5 +20,5 @@ export function proxy(request: NextRequest) {
} }
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"], matcher: ["/((?!_next/static|_next/image|favicon.ico|api|health).*)"],
}; };

34
src/server/index.ts Normal file → Executable file
View file

@ -4,6 +4,7 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { projectRouter } from "./routes/project.routes.js"; import { projectRouter } from "./routes/project.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js"; import { errorMiddleware } from "./middleware/error.middleware.js";
import { createLogger, toErrorMeta } from "../shared/logging/logger.js"; import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
import { closeDefaultDatabase } from "../db/client.js";
const logger = createLogger("api"); const logger = createLogger("api");
const app = express(); const app = express();
@ -62,8 +63,35 @@ process.on("unhandledRejection", (reason) => {
process.exit(1); process.exit(1);
}); });
process.on("SIGTERM", () => logger.info("received SIGTERM")); let shuttingDown = false;
process.on("SIGINT", () => logger.info("received SIGINT"));
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(() => { setInterval(() => {
const memory = process.memoryUsage(); const memory = process.memoryUsage();
@ -74,6 +102,6 @@ setInterval(() => {
}); });
}, heartbeatIntervalMs).unref(); }, heartbeatIntervalMs).unref();
app.listen(port, () => { const server = app.listen(port, () => {
logger.info("server started", { port }); logger.info("server started", { port });
}); });

View file

@ -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;
}