Files
HealthLog/docs/self-hosting/scaling.md
T
Marc-André Bombeck 4f6d2ccbeb docs: drop per-release audit notes + legacy spec, scrub phase markers
- /docs/audit/ moved out of the public docs surface (23 per-release
  forensic notes). They were linked from the README but read as
  internal traceability artefacts, not user-facing documentation.
  Kept local for cross-release lookup; added to .gitignore so new
  ones don't re-track.
- /docs/api/openapi-v1422-legacy.yaml removed — superseded by the
  current openapi.yaml on the v1.5 line.
- /docs/api/openapi.yaml info.version bumped 1.4.23 → 1.5.0 so the
  spec matches package.json (the doc claims the version is mirrored
  from the package; previously the mirror had drifted six versions).
- /docs/ops/deploy.md: image name corrected from
  `ghcr.io/marc-bombeck/healthlog` → `ghcr.io/mbombeck/healthlog`
  (the previous command would have 404'd at GHCR); release-process
  framing dropped from a release-pair note.
- /docs/ops/backup-restore.md + /docs/self-hosting/scaling.md:
  trailing phase tags (`v1.4 G1`, `v1.4 G3`, `v1.4.40 W-POOL`)
  stripped from section headers + body. Replaced an internal-notes
  pointer with a concrete `getPoolMax()` reference.

The README pointer to /docs/audit/ is removed in the next commit
along with the rest of the README accuracy pass.
2026-05-25 23:43:58 +02:00

123 lines
5.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Scaling: web/worker container split
The default `docker compose up` launches a single container that runs
both the Next.js HTTP server AND the pg-boss worker. That works fine
for a personal deployment but is hard to scale horizontally because
every replica also runs the full reminder/insight cron schedule.
v1.4 introduces an environment-variable gate so the same image can be
deployed in three modes:
| `HEALTHLOG_PROCESS_TYPE` | Web | Worker | Use when |
| ------------------------ | --- | ------ | ------------------------- |
| `all` (default) | ✅ | ✅ | single-host self-hosting |
| `web` | ✅ | ❌ | scaling HTTP horizontally |
| `worker` | ❌ | ✅ | dedicated job worker |
## How to split
1. Edit `docker-compose.yml`: set `HEALTHLOG_PROCESS_TYPE=web` on the
`app` service and uncomment the `app-worker` service block at the
bottom (it ships with `HEALTHLOG_PROCESS_TYPE=worker` baked in).
2. Make sure both containers see the same `DATABASE_URL`,
`ENCRYPTION_KEY` (or `ENCRYPTION_KEYS`), and `API_TOKEN_HMAC_KEY`.
3. `docker compose up -d --build`.
Both containers connect to the same Postgres. pg-boss claims jobs
atomically via row-level locking, so it is safe to run multiple worker
replicas — they will share the load.
## Healthchecks
- `app` (web) healthcheck: `wget /api/version` every 30s.
- `app-worker` does not expose a port; its liveness is reported into
the `worker_status` table on every reminder pass and surfaced through
`/api/admin/worker-status`.
The web container does NOT depend on the worker, and vice versa, so
neither container's startup can deadlock the other. The shared
dependency is Postgres; both wait on `db: condition: service_healthy`.
## Caveats
- Background tasks that touch user-scoped Wide Events (e.g. reminder
notifications) emit telemetry from the worker; configure `LOKI_*`
env vars on the worker if you want them.
- The off-host backup job runs in the worker only. The admin
`POST /api/admin/backup/test` endpoint runs in the web container and
exercises the same S3 credentials — set `BACKUP_*` env vars on BOTH.
- The startup gate (`assertSubsystemEnabled`) refuses to boot the
reminder worker when `HEALTHLOG_PROCESS_TYPE=web`, so an accidental
cross-mode invocation aborts immediately instead of silently doubling
cron load.
## Postgres connection-pool sizing
Every container that boots the HealthLog image opens its own
`pg.Pool` against the configured `DATABASE_URL`. The pool ceiling is
**20 connections per container by default** (raised from the library
default of 10 in v1.4.40 to keep the dashboard's analytics fan-out
from starving every other query during a cold mount).
Plan total Postgres slots as **container_count × `DATABASE_POOL_MAX`**.
A stock Postgres 16 container ships with `max_connections = 100`, so
the safe envelope is:
| Web replicas | Worker replicas | Total containers | Pool slots used | Headroom under 100 |
| ------------ | --------------- | ---------------- | --------------- | ------------------ |
| 1 | 0 | 1 | 20 | 80 |
| 1 | 1 | 2 | 40 | 60 |
| 2 | 1 | 3 | 60 | 40 |
| 3 | 1 | 4 | 80 | 20 |
| 4 | 1 | 5 | 100 | 0 (do not exceed) |
Once the table tips into the "0 headroom" row, every other client of
the same Postgres — `psql` sessions, ad-hoc backups, the Prisma CLI
during a migration deploy — will get `FATAL: sorry, too many clients
already`. Either raise `max_connections` on the Postgres side, lower
`DATABASE_POOL_MAX`, or front the database with PgBouncer in
transaction-pooling mode.
### Overriding the per-container pool ceiling
Set `DATABASE_POOL_MAX` to a positive integer on every container that
needs a non-default ceiling — both web and worker pick up the same
env var:
```yaml
# docker-compose.yml — example for a 6-container deployment
services:
app:
image: ghcr.io/mbombeck/healthlog:latest
environment:
DATABASE_POOL_MAX: "12" # 6 containers × 12 = 72 < 100
HEALTHLOG_PROCESS_TYPE: web
```
Rules of thumb:
- Each web replica's hottest path (`/api/analytics` fan-out, capped at
`p-limit(4)`) consumes 4 concurrent slots. Keep
`DATABASE_POOL_MAX ≥ 8` so a single power-user request never
exhausts the pool of one container.
- Worker replicas mostly use 1 connection per active pg-boss job;
`DATABASE_POOL_MAX = 8` is plenty unless you raised the pg-boss
`teamConcurrency`.
- If you raise Postgres `max_connections` past 100, prefer raising
`DATABASE_POOL_MAX` over adding more containers — fewer, fatter
Node processes amortise the V8 footprint better than many thin
ones.
### Why the default is 20
The v1.4.39 empirical cold-mount trace showed thick `/api/analytics`
holding ≥ 8 of the 10 default pool slots for 6.5 s on a 347 k-row
tenant, starving every other dashboard query for the duration. The
20-slot default — paired with the `p-limit(4)` cap on the analytics
fan-out — keeps 16 slots free for the rest of the dashboard while
still sitting well under Postgres's 100-slot stock ceiling.
The implementation lives in `src/lib/db.ts → getPoolMax()`; the
20-slot default was chosen after measuring on a 4-CPU production
container — see the inline rationale in `getPoolMax()`.