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 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-08-15 00:26:56 +02:00
parent a17e2e3f4b
commit 01fa527b9c
14 changed files with 424 additions and 118 deletions

2
.gitignore vendored Normal file → Executable file
View file

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

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

51
Dockerfile Normal file → Executable file
View file

@ -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"]

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,
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

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

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

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

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
# 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

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

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

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");
// 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).*)"],
};

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 { 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 });
});

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