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

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