From 2e3e85a8e52cfd1bd3434b9cc03af22bf164119d Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 16 Sep 2026 11:04:34 +0200 Subject: [PATCH] Harden the container runtime and shut down cleanly Running the stack on built output stopped the idle CPU load, but the remaining findings from the deployment report were still open. The Dockerfile is now multi-stage. The prod target runs on node:24-slim as the "node" user instead of root and carries only its runtime dependencies; "npm prune --omit=dev" drops tsx, drizzle-kit and typescript. The migration scripts need drizzle-orm and better-sqlite3, both regular dependencies, so they keep working. The dev target keeps devDependencies for compose.dev.yaml. Both compose files get a mem_limit. The server stack also sets memswap_limit to the same value, so a leak lets the OOM killer replace the container instead of pushing the host into swap. The Express API is no longer published on the host: it has no authentication and is reached through the Next.js rewrite. Development ports are bound to the loopback interface. Services drop all capabilities and run with no-new-privileges. SIGTERM and SIGINT now close the HTTP server and the SQLite handle and exit, instead of only logging the signal and waiting for the container's SIGKILL ten seconds later. server.closeIdleConnections() releases the keep-alive sockets the Next.js server holds, which would otherwise keep server.close() pending for the whole grace period. The compose start commands exec into node, because otherwise sh stays the foreground process and the signal never reaches it. Delete scripts/docker-start.sh rather than fixing its unsupervised background API: both compose stacks set their own command, so the single-container entrypoint has no caller left. Also remove drizzle.config.js (a stray tsc artifact drizzle-kit never reads) and the pre-Next index.html and styles.css. Co-Authored-By: Claude Opus 5 --- .dockerignore | 3 ++ .gitignore | 2 ++ Dockerfile | 57 +++++++++++++++++++++++++++++------- README.md | 13 +++++++-- compose.dev.yaml | 56 ++++++++++++++++++++++------------- compose.yaml | 65 ++++++++++++++++++++++++++++++----------- docs/deployment.md | 56 ++++++++++++++++++++++++++++++----- drizzle.config.js | 11 ------- index.html | 17 ----------- scripts/docker-start.sh | 16 ---------- src/db/client.ts | 7 +++++ src/server/index.ts | 41 +++++++++++++++++++++++--- styles.css | 15 ---------- 13 files changed, 238 insertions(+), 121 deletions(-) delete mode 100644 drizzle.config.js delete mode 100644 index.html delete mode 100644 scripts/docker-start.sh delete mode 100644 styles.css diff --git a/.dockerignore b/.dockerignore index 80054d0..abd6bc2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,6 @@ data *.db .env .env.* +# Lokale Werkzeug- und Agenten-Artefakte gehoeren nicht ins Image. +.codex +.agents diff --git a/.gitignore b/.gitignore index ed5ffe6..e0ef1d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ dist/ .next/ +# Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts. +/drizzle.config.js data/*.db data/backups/*.db .codex/*.log diff --git a/Dockerfile b/Dockerfile index 31d28c4..66147e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,56 @@ -FROM node:24 -WORKDIR /app +# syntax=docker/dockerfile:1 +# --------------------------------------------------------------------------- +# deps: vollstaendiger Abhaengigkeitsbaum, gemeinsam genutzt von "dev" und +# "build". Bleibt auf dem vollen node-Image, weil better-sqlite3 beim +# Installieren eine Toolchain braucht. +# --------------------------------------------------------------------------- +FROM node:24 AS deps +WORKDIR /app COPY package*.json ./ RUN npm ci +# --------------------------------------------------------------------------- +# dev: Ziel fuer compose.dev.yaml. Behaelt devDependencies, damit tsx, +# drizzle-kit und "next dev" verfuegbar sind. Die Quellen kommen zur Laufzeit +# ueber Bind-Mounts, das COPY hier ist nur der Ausgangsstand. +# --------------------------------------------------------------------------- +FROM deps AS dev +ENV NODE_ENV=development COPY . . +RUN mkdir -p data +EXPOSE 3000 3001 +CMD ["npm", "run", "dev:api"] -# next build writes the rewrite destinations from next.config.mjs into -# .next/routes-manifest.json, so "next start" cannot pick up a different -# API URL later. The value has to be known here, not just at runtime. +# --------------------------------------------------------------------------- +# build: API nach dist/ und Frontend nach .next/ uebersetzen, danach die +# devDependencies entfernen, damit das Laufzeit-Image nur noch traegt, was es +# auch ausfuehrt. Die Migrationsskripte kommen mit drizzle-orm und +# better-sqlite3 aus, beides sind regulaere dependencies. +# --------------------------------------------------------------------------- +FROM deps AS build +ENV NEXT_TELEMETRY_DISABLED=1 +# next build schreibt die Rewrite-Ziele aus next.config.mjs fest in +# .next/routes-manifest.json, "next start" kann eine andere API-Adresse +# spaeter nicht mehr uebernehmen. Der Wert muss hier bekannt sein, nicht erst +# zur Laufzeit. Server-Komponenten lesen die Variable zur Laufzeit. 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: Laufzeit-Image. node:24-slim teilt sich das Debian-Release mit der +# build-Stufe, damit das kompilierte better-sqlite3-Binding ladbar bleibt. +# Laeuft als "node" (UID 1000) statt als root. +# --------------------------------------------------------------------------- +FROM node:24-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 index fc82fca..58f1832 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,20 @@ docker compose up --build --detach Danach: - Frontend: -- API-Healthcheck: +- API-Healthcheck: (über den Next.js-Rewrite) + +Die Express-API ist nicht mehr auf dem Host veröffentlicht. Sie hat keine +Authentifizierung und ist nur innerhalb des Compose-Netzes erreichbar; der +Zugriff läuft über den Rewrite im Web-Dienst. Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start automatisch aus. Die SQLite-Datei liegt auf dem Host unter `data/leistungsbilanz.db` und bleibt beim Stoppen erhalten. +Das Laufzeit-Image läuft als Benutzer `node` (UID 1000), nicht als root. Ein +`data/`-Verzeichnis, das ein früherer root-Container angelegt hat, braucht +deshalb einmalig `sudo chown -R 1000:1000 data` auf dem Host. + ```powershell docker compose ps docker compose logs --follow @@ -63,7 +71,8 @@ docker compose down ``` `compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`, -ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in +ohne Quellcode-Mounts und ohne Datei-Watcher, mit Speichergrenzen und ohne +root-Rechte im Container. Details stehen in [Deployment und Betrieb](docs/deployment.md). Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit diff --git a/compose.dev.yaml b/compose.dev.yaml index 86e42e2..b9c64b4 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -1,35 +1,47 @@ -# Development stack: source mounts, watching dev servers, hot reload. +# Entwicklungs-Stack: Quellcode-Mounts, laufende Dev-Server, Hot Reload. # docker compose -f compose.dev.yaml up --build # -# The polling watchers below are needed for bind mounts on Windows and -# macOS, where inotify events do not cross the VM boundary. They cost -# continuous CPU, which is why the production stack in compose.yaml does -# not run watchers at all. +# Die Polling-Watcher unten sind fuer Bind-Mounts unter Windows und macOS +# noetig, weil inotify-Events die VM-Grenze nicht passieren. Sie kosten +# dauerhaft CPU; genau deshalb laufen im Server-Stack (compose.yaml) gar +# keine Watcher. +# +# Der Projektname muss sich von dem des Server-Stacks unterscheiden, damit +# "docker compose down" in diesem Verzeichnis niemals einen laufenden +# Server-Stack abraeumen kann. name: leistungsbilanz-dev -x-logging: &logging - driver: json-file - options: - max-size: "20m" - max-file: "10" +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 + - npm run db:migrate && npm run db:verify:circuit-schema && exec npm run dev:api environment: PORT: "3000" CHOKIDAR_USEPOLLING: "true" LOG_LEVEL: "${LOG_LEVEL:-debug}" - init: true - restart: unless-stopped - logging: *logging + # Nur auf dem Loopback: nichts davon ist authentifiziert, die API darf + # nicht aus dem LAN erreichbar sein. ports: - - "3000:3000" + - "127.0.0.1:3000:3000" + # Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als + # Puffer erwuenscht, die Grenze soll nur den Host vor Dauerlast schuetzen. + mem_limit: 1g volumes: - ./src:/app/src - ./scripts:/app/scripts @@ -41,15 +53,17 @@ services: - CMD - node - -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=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) interval: 30s timeout: 3s retries: 5 start_period: 20s web: + <<: *service-defaults build: context: . + target: dev command: - npm - run @@ -62,14 +76,14 @@ services: WATCHPACK_POLLING: "true" NEXT_TELEMETRY_DISABLED: "1" LOG_LEVEL: "${LOG_LEVEL:-debug}" - init: true - restart: unless-stopped - logging: *logging depends_on: api: condition: service_healthy ports: - - "3001:3001" + - "127.0.0.1:3001:3001" + # "next dev" haelt Kompilierzustand und waechst im Dauerbetrieb stetig; + # die Grenze macht daraus einen Container-Neustart statt Host-Swapping. + mem_limit: 2g volumes: - ./src:/app/src - ./next.config.mjs:/app/next.config.mjs:ro @@ -80,7 +94,7 @@ services: - CMD - node - -e - - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + - fetch('http://127.0.0.1:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) interval: 30s timeout: 3s retries: 5 diff --git a/compose.yaml b/compose.yaml index 503a4b2..3438f90 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,33 +1,59 @@ +# Server-Stack: gebauter Stand, keine Quellcode-Mounts, keine Datei-Watcher. +# docker compose up --build --detach +# +# Fuer die Entwicklung mit Hot Reload compose.dev.yaml verwenden. name: leistungsbilanz x-build: &build context: . + target: prod args: - # Baked into .next/routes-manifest.json by next build; see Dockerfile. + # Wird von next build in .next/routes-manifest.json eingebacken, siehe + # Dockerfile. Muss der API-Adresse in diesem Netz entsprechen. API_INTERNAL_URL: http://api:3000 -x-logging: &logging - driver: json-file - options: - max-size: "20m" - max-file: "10" +x-service-defaults: &service-defaults + init: true + restart: unless-stopped + # Der API-Prozess beendet HTTP-Server und SQLite-Handle auf SIGTERM; die + # Frist gibt laufenden Requests Zeit, statt sie nach 10s in ein SIGKILL + # laufen zu lassen. + stop_grace_period: 20s + # Das Image laeuft als "node" (UID 1000); zusaetzliche Rechte braucht + # weder der API- noch der Web-Prozess. + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + logging: + driver: json-file + options: + max-size: "20m" + max-file: "10" services: api: + <<: *service-defaults build: *build + # "exec" beim letzten Schritt: sonst bleibt sh der Vordergrundprozess und + # SIGTERM erreicht node nie. command: - sh - -c - - node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js + - node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && exec node dist/server/index.js environment: NODE_ENV: production PORT: "3000" LOG_LEVEL: "${LOG_LEVEL:-info}" - init: true - restart: unless-stopped - logging: *logging - ports: - - "3000:3000" + # Nicht auf dem Host veroeffentlicht: die API hat keine Authentifizierung + # und wird ueber den Next.js-Rewrite im Web-Dienst erreicht. + expose: + - "3000" + # Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit + # schaltet Swap fuer den Container ab, damit ein Leck den Container trifft + # und nicht den Host. + mem_limit: 512m + memswap_limit: 512m volumes: - ./data:/app/data healthcheck: @@ -35,38 +61,43 @@ services: - CMD - node - -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=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) interval: 30s timeout: 3s retries: 5 start_period: 20s web: + <<: *service-defaults build: *build command: - node_modules/.bin/next - start - -p - "3001" + - --hostname + - 0.0.0.0 environment: NODE_ENV: production API_INTERNAL_URL: http://api:3000 NEXT_TELEMETRY_DISABLED: "1" LOG_LEVEL: "${LOG_LEVEL:-info}" - init: true - restart: unless-stopped - logging: *logging depends_on: api: condition: service_healthy ports: - "3001:3001" + mem_limit: 1g + memswap_limit: 1g healthcheck: + # /web-health statt /health: next.config.mjs leitet /health an die API + # weiter, und "/" wuerde wegen des Redirects auf /projects bei jeder + # Probe zwei Seiten rendern. test: - CMD - node - -e - - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + - fetch('http://127.0.0.1:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) interval: 30s timeout: 3s retries: 5 diff --git a/docs/deployment.md b/docs/deployment.md index 06289a7..909c2c0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -6,7 +6,9 @@ Es gibt zwei Compose-Stacks. `compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und `next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack -für einen Server. +für einen Server. Beide Dienste laufen aus der `prod`-Stufe des Dockerfiles, +also als Benutzer `node` statt als root, ohne devDependencies, mit +`cap_drop: ALL`, `no-new-privileges` und harten Speichergrenzen. `compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows @@ -20,11 +22,37 @@ erreichbar gemacht werden. ## Topologie -| Komponente | Port | Healthcheck | Persistenz | -| --- | ---: | --- | --- | -| Next.js Web | 3001 | `GET /web-health` | keine | -| Express API | 3000 | `GET /health` | `./data:/app/data` | -| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | +| Komponente | Port | Auf dem Host | Healthcheck | Persistenz | +| --- | ---: | --- | --- | --- | +| Next.js Web | 3001 | `3001` (Server) / `127.0.0.1:3001` (Dev) | `GET /web-health` | keine | +| Express API | 3000 | nicht veröffentlicht (Server) / `127.0.0.1:3000` (Dev) | `GET /health` | `./data:/app/data` | +| SQLite | Datei | – | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | + +Die Express-API wird im Server-Stack nicht mehr auf dem Host veröffentlicht +(`expose` statt `ports`). Sie hat keine Authentifizierung; jeder, der sie +erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem +Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack +hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar. + +### Speichergrenzen + +Jeder Dienst hat ein `mem_limit`. Im Server-Stack ist zusätzlich +`memswap_limit` auf denselben Wert gesetzt, womit Swap für den Container +abgeschaltet ist: ein Speicherleck lässt dann den Container am OOM-Killer +sterben und `restart: unless-stopped` greift, statt dass der Host in den Swap +läuft. Aktuell 512 MB für die API und 1 GB für das Frontend. Der +Entwicklungs-Stack bekommt 1 GB bzw. 2 GB und behält Swap, weil `next dev` +Kompilierzustand hält und im Dauerbetrieb stetig wächst. + +### Rechte im Container + +Das Laufzeit-Image läuft als `node` (UID 1000). Ein `data/`-Verzeichnis, das +ein früherer root-Container angelegt hat, ist dann nicht beschreibbar und +braucht einmalig auf dem Host: + +```bash +sudo chown -R 1000:1000 data +``` Verwendete Umgebungsvariablen: @@ -83,11 +111,25 @@ einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit `restart: unless-stopped`, damit Docker sie danach automatisch neu startet; ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen. +## Herunterfahren + +`SIGTERM` und `SIGINT` schließen in der API den HTTP-Server und anschließend +das SQLite-Handle und beenden den Prozess. Offene Keep-Alive-Verbindungen – +vor allem die des Next.js-Servers – werden dabei über +`server.closeIdleConnections()` freigegeben, laufende Requests dürfen zu Ende +laufen. Vorher wurde das Signal nur geloggt, der Prozess lief weiter und +Docker beendete ihn nach zehn Sekunden mit `SIGKILL`, mitten in einem +möglichen Schreibvorgang. Greift der Abbau nicht innerhalb von 15 Sekunden, +beendet sich der Prozess selbst mit Exit-Code 1; `stop_grace_period: 20s` in +beiden Compose-Dateien liegt bewusst darüber. + +Die Startbefehle in `compose.yaml` enden mit `exec node …`. Ohne das `exec` +bleibt `sh` der Vordergrundprozess, und `SIGTERM` erreicht Node nie. + ## Voraussetzungen für ein späteres Produktionssetup Vor einer produktiven Installation werden mindestens benötigt: -- reproduzierbare Produktionsimages und ein Next.js-Produktionsstartskript - TLS-Termination und Reverse Proxy - Authentifizierung, Autorisierung und Benutzer-/Rollenmodell - definierte Secrets- und Konfigurationsverwaltung 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 deleted file mode 100644 index bf0d856..0000000 --- a/scripts/docker-start.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -set -e - -echo "Running migrations..." -node scripts/run-migrations.js - -echo "Starting API server on :3000..." -node dist/server/index.js & - -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 - sleep 1 -done - -echo "Starting Next.js on :3001..." -exec node_modules/.bin/next start -p 3001 diff --git a/src/db/client.ts b/src/db/client.ts index 02cfc7e..4d0c97b 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -7,3 +7,10 @@ const defaultDatabaseContext = createDatabaseContext( export const db = defaultDatabaseContext.db; +/** + * Schliesst das SQLite-Handle des Prozesses. Nur beim Herunterfahren + * aufrufen: danach schlaegt jede Abfrage ueber {@link db} fehl. + */ +export function closeDefaultDatabase(): void { + defaultDatabaseContext.close(); +} diff --git a/src/server/index.ts b/src/server/index.ts index 2f6ac5e..1f5c9b2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,11 +4,13 @@ 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(); const port = Number(process.env.PORT || 3000); const heartbeatIntervalMs = 5 * 60 * 1000; +const shutdownTimeoutMs = 15 * 1000; app.use(express.json({ limit: "25mb" })); @@ -62,9 +64,6 @@ process.on("unhandledRejection", (reason) => { process.exit(1); }); -process.on("SIGTERM", () => logger.info("received SIGTERM")); -process.on("SIGINT", () => logger.info("received SIGINT")); - setInterval(() => { const memory = process.memoryUsage(); logger.verbose("heartbeat", { @@ -74,6 +73,40 @@ setInterval(() => { }); }, heartbeatIntervalMs).unref(); -app.listen(port, () => { +const server = app.listen(port, () => { logger.info("server started", { port }); }); + +let shuttingDown = false; + +function shutdown(signal: NodeJS.Signals): void { + if (shuttingDown) return; + shuttingDown = true; + logger.info("shutting down", { signal }); + + // Falls ein Request haengt oder ein Socket nicht freigegeben wird, soll der + // Prozess trotzdem enden, statt auf das SIGKILL des Containers zu warten. + const forceExit = setTimeout(() => { + logger.error("shutdown timed out, forcing exit"); + process.exit(1); + }, shutdownTimeoutMs); + 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); + }); + + // Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des + // Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter. + server.closeIdleConnections(); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); 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; -}