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
First-time contributor

Problem

The development stack had been running permanently on a server. After five days the web container held 10.3 GB RSS, the API container burned a steady 12% CPU with nobody using the application, and the host had 13 GB of swap in use at load average 4.85.

Four causes combined:

  1. Polling watchers. CHOKIDAR_USEPOLLING and WATCHPACK_POLLING made tsx watch walk the source tree continuously.
  2. The healthcheck rendered a full page. It probed / every 5 s. / is redirect("/projects") and Node's fetch follows redirects, so every probe server-rendered the project list -- roughly 16,000 times a day.
  3. No memory limit. next dev keeps compilation state and grew unbounded; the next-server process had already been replaced once and was back at 10 GB within 12 hours.
  4. Colliding project name. compose.yaml was named leistungsbilanz, the same as the production project, so docker compose down in the development directory targeted the production stack.

Changes

  • add compose.prod.yaml: compiled output, separate api/web services, no bind mounts, no watchers
  • multi-stage Dockerfile with dev and prod targets; the runtime image drops devDependencies and runs as node instead of root
  • healthchecks probe /health every 30 s instead of / every 5 s
  • mem_limit on every service, plus memswap_limit in production so a leak kills the container instead of the host
  • development project renamed to leistungsbilanz-dev, development ports bound to 127.0.0.1
  • the Express API is no longer published on the host; it has no authentication and is reached through the Next.js rewrite
  • SIGTERM/SIGINT now close the HTTP server and the SQLite handle and exit, instead of only logging the signal
  • scripts/docker-start.sh exits when either supervised process dies, instead of leaving a container marked up while serving a dead API
  • remove drizzle.config.js (a compiled copy drizzle-kit never reads) and the pre-Next index.html/styles.css

Latent bug found while verifying

Next.js resolves next.config.mjs rewrites at build time and writes the literal destination into .next/routes-manifest.json. API_INTERNAL_URL set at runtime is therefore ignored by next start -- the manifest contained a hardcoded http://localhost:3000. Server components read the variable correctly at runtime, but every browser call to /api/* goes through the baked rewrite. The single-container deployment only worked because both processes shared a localhost. Splitting the services would have broken all client-side writes. API_INTERNAL_URL is now a build argument; docs/deployment.md documented the previous behaviour incorrectly and has been corrected.

Verification

  • npm test: 412/412 pass
  • build:api, build:web, typecheck:scripts, tsc --noEmit -p tsconfig.next.json: all clean
  • production image built and started: migrations applied, database writable as node, /health 200, /api/projects 200 through the rewrite
  • docker stop completes in 392 ms with exit code 0, previously a 10 s timeout ending in SIGKILL
  • both compose files validate and resolve to distinct project names

A note for whoever deploys this: the production image runs as node, so a data volume written by an earlier root container needs a one-time chown. The command is in docs/deployment.md.


🤖 Generated with Claude Code

## Problem The development stack had been running permanently on a server. After five days the web container held 10.3 GB RSS, the API container burned a steady 12% CPU with nobody using the application, and the host had 13 GB of swap in use at load average 4.85. Four causes combined: 1. **Polling watchers.** `CHOKIDAR_USEPOLLING` and `WATCHPACK_POLLING` made `tsx watch` walk the source tree continuously. 2. **The healthcheck rendered a full page.** It probed `/` every 5 s. `/` is `redirect("/projects")` and Node's `fetch` follows redirects, so every probe server-rendered the project list -- roughly 16,000 times a day. 3. **No memory limit.** `next dev` keeps compilation state and grew unbounded; the `next-server` process had already been replaced once and was back at 10 GB within 12 hours. 4. **Colliding project name.** `compose.yaml` was named `leistungsbilanz`, the same as the production project, so `docker compose down` in the development directory targeted the production stack. ## Changes - add `compose.prod.yaml`: compiled output, separate api/web services, no bind mounts, no watchers - multi-stage `Dockerfile` with `dev` and `prod` targets; the runtime image drops devDependencies and runs as `node` instead of root - healthchecks probe `/health` every 30 s instead of `/` every 5 s - `mem_limit` on every service, plus `memswap_limit` in production so a leak kills the container instead of the host - development project renamed to `leistungsbilanz-dev`, development ports bound to `127.0.0.1` - the Express API is no longer published on the host; it has no authentication and is reached through the Next.js rewrite - `SIGTERM`/`SIGINT` now close the HTTP server and the SQLite handle and exit, instead of only logging the signal - `scripts/docker-start.sh` exits when either supervised process dies, instead of leaving a container marked up while serving a dead API - remove `drizzle.config.js` (a compiled copy drizzle-kit never reads) and the pre-Next `index.html`/`styles.css` ### Latent bug found while verifying Next.js resolves `next.config.mjs` rewrites at build time and writes the literal destination into `.next/routes-manifest.json`. `API_INTERNAL_URL` set at runtime is therefore ignored by `next start` -- the manifest contained a hardcoded `http://localhost:3000`. Server components read the variable correctly at runtime, but every browser call to `/api/*` goes through the baked rewrite. The single-container deployment only worked because both processes shared a localhost. Splitting the services would have broken all client-side writes. `API_INTERNAL_URL` is now a build argument; `docs/deployment.md` documented the previous behaviour incorrectly and has been corrected. ## Verification - `npm test`: 412/412 pass - `build:api`, `build:web`, `typecheck:scripts`, `tsc --noEmit -p tsconfig.next.json`: all clean - production image built and started: migrations applied, database writable as `node`, `/health` 200, `/api/projects` 200 through the rewrite - `docker stop` completes in 392 ms with exit code 0, previously a 10 s timeout ending in SIGKILL - both compose files validate and resolve to distinct project names A note for whoever deploys this: the production image runs as `node`, so a data volume written by an earlier root container needs a one-time `chown`. The command is in `docs/deployment.md`. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Grovy311 added 1 commit 2026-08-15 00:56:23 +02:00
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 pull request has changes conflicting with the target branch.
  • .gitignore
  • Dockerfile
  • README.md
  • compose.yaml
  • docs/deployment.md
  • src/proxy.ts
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u fix/production-deployment:Grovy311-fix/production-deployment
git checkout Grovy311-fix/production-deployment

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git checkout main
git merge --no-ff Grovy311-fix/production-deployment
git checkout Grovy311-fix/production-deployment
git rebase main
git checkout main
git merge --ff-only Grovy311-fix/production-deployment
git checkout Grovy311-fix/production-deployment
git rebase main
git checkout main
git merge --no-ff Grovy311-fix/production-deployment
git checkout main
git merge --squash Grovy311-fix/production-deployment
git checkout main
git merge --ff-only Grovy311-fix/production-deployment
git checkout main
git merge Grovy311-fix/production-deployment
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: jappel/leistungsbilanz-ts#2
No description provided.