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

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: