From fa7e1ee73959cd71b65eb84ed868001364980631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 27 Aug 2026 12:21:27 +0200 Subject: [PATCH] Bring the self-hosting guides in line with the shipped code - geolite2: quote the real commented compose mount (./geolite2 to /opt/geolite2, the GEOLITE2_DIR default, so no env var is needed for the default path) - getting-started: healthcheck probes /api/health, not /api/version; the environment module reaches archive-api and geocoding-api .open-meteo.com, relevant for egress allowlists - mcp: the module is opt-in and off by default, but no longer the only such module - reverse-proxy: the Coach stream is SSE over plain HTTP, not WebSockets; note that TRUST_PROXY_HOPS is not on the bundled compose whitelist and must be added by hand - scaling: /api/health healthcheck; worker liveness is in-memory and invisible across a web/worker split; pool sizing now goes through getConnectionBudget with DB_CONNECTION_LIMIT as the compose knob and a 2-connection pg-boss share - sso: the native SSO handoff shipped, OIDC_ONLY works with app builds that carry it - unraid: geo databases are baked in only when the build had a licence key, or mounted; replaceable either way --- docs/ops/deploy.md | 4 +-- docs/ops/encryption-key-rotation.md | 10 +++---- docs/self-hosting/geolite2.md | 12 ++++++--- docs/self-hosting/getting-started.md | 8 +++--- docs/self-hosting/mcp.md | 2 +- docs/self-hosting/reverse-proxy.md | 24 +++++++++++------ docs/self-hosting/scaling.md | 40 +++++++++++++++++----------- docs/self-hosting/sso.md | 8 +++--- docs/self-hosting/unraid.md | 3 ++- 9 files changed, 70 insertions(+), 41 deletions(-) diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index f52e3c927..289efa4dc 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -44,8 +44,8 @@ and `WHOOP_WEBHOOK_SECRET` (HMAC secret for WHOOP webhook signature verification). Per-user WHOOP client id/secret live in Settings, not in the environment. See `docs/integrations/whoop.md`. The shipped `docker-compose.yml` already whitelists both under `environment:`; only -a forked or older compose file needs them added by hand — vars not on -the whitelist never reach the container. +a forked or older compose file needs them added by hand, since vars not +on the whitelist never reach the container. ### What goes wrong if you reverse it diff --git a/docs/ops/encryption-key-rotation.md b/docs/ops/encryption-key-rotation.md index 7a06fe83f..9c28dad2a 100644 --- a/docs/ops/encryption-key-rotation.md +++ b/docs/ops/encryption-key-rotation.md @@ -48,12 +48,12 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`). ``` The script is idempotent — running it again is a no-op for rows already prefixed with `v2.`. It rotates every registered encrypted column across - all models — the canonical registry lives in - `src/lib/crypto/encrypted-columns.ts`, and a guard test fails CI if any - `*Encrypted` column in the schema is missing from the registry or not - referenced by the script — plus the two non-`*Encrypted` members: + all models, plus the two non-`*Encrypted` members: `IntegrationStatus.lastError` and `CoachMessage.encryptedContent` - (Bytes). The summary line at the + (Bytes). The canonical registry lives in + `src/lib/crypto/encrypted-columns.ts`; a guard test fails CI if any + `*Encrypted` column in the schema is missing from the registry or not + referenced by the script. The summary line at the end shows `scanned`, `rotated`, and `errors` per table+field; treat `errors > 0` as a hard failure and re-run after fixing the cause. 4. **Drop the old key (optional, recommended).** Once the script has run diff --git a/docs/self-hosting/geolite2.md b/docs/self-hosting/geolite2.md index 7910de171..98a1d9b73 100644 --- a/docs/self-hosting/geolite2.md +++ b/docs/self-hosting/geolite2.md @@ -95,10 +95,15 @@ fails soft. ```yaml volumes: - - /srv/healthlog/geolite2:/var/lib/geolite2:ro + - ./geolite2:/opt/geolite2:ro ``` -3. Point the runtime at the container path: + Swap the host side (`./geolite2`) for your directory, e.g. + `/srv/healthlog/geolite2`. + +3. `/opt/geolite2` is the default value of `GEOLITE2_DIR`, so mounting + at that container path needs no env var at all. Only if you mount at + a different container path do you set it: ```env GEOLITE2_DIR="/var/lib/geolite2" @@ -163,7 +168,8 @@ and it is served without a session for exactly that reason. **System status still says the online provider.** The readiness check requires `GeoLite2-City.mmdb` to exist in `GEOLITE2_DIR` and no `.empty` marker file beside it. Check the path from inside the container: -`docker compose exec app ls -l /var/lib/geolite2`. An empty listing means +`docker compose exec app ls -l /opt/geolite2` (or your `GEOLITE2_DIR` +value). An empty listing means the bind mount did not land; a `.empty` file means the image was built without a licence key and you are still reading the baked-in directory rather than your own. diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 644d3ed8b..9053dae7c 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -17,7 +17,8 @@ already installed, longer if a TLS-fronted public hostname is in scope - **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever integration endpoints you plan to enable (Withings, OpenAI, etc.). The opt-in environmental-context module additionally reaches - `api.open-meteo.com` for weather and daylight; it stays off until you + `archive-api.open-meteo.com` and `geocoding-api.open-meteo.com` for + weather and daylight; it stays off until you enable it and set a home location, and every outbound call goes through the SSRF-guarded egress wrapper. - **Optional but recommended:** a reverse proxy that terminates TLS @@ -191,8 +192,9 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build `app_settings.singleton.registrationEnabled`. The admin panel can flip it back if a previous owner closed it. - **Service unreachable on `localhost:3000`.** Check `docker compose -ps` — the `app` healthcheck polls `/api/version` every 30 seconds - and will mark the container unhealthy if Postgres is still +ps` — the `app` healthcheck polls `/api/health` every 30 seconds + (it returns 503 while the database or worker is down) and will mark + the container unhealthy if Postgres is still starting. Tail `docker compose logs db` to confirm Postgres came up cleanly. - **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and diff --git a/docs/self-hosting/mcp.md b/docs/self-hosting/mcp.md index 9afc189f3..1753438a1 100644 --- a/docs/self-hosting/mcp.md +++ b/docs/self-hosting/mcp.md @@ -30,7 +30,7 @@ Three things must be true before `/mcp` will answer: | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`APP_URL` is set** | The endpoint fails closed without a pinned canonical origin. `APP_URL` (or `NEXT_PUBLIC_APP_URL`) is the origin every OAuth document and audience check is anchored to — `Host` is never trusted. Without it the surface stays invisible (`404`). | | **The global API is on** | The operator's instance-wide API switch must be enabled (it is by default). With it off, every token path — including `/mcp` — returns `404`. | -| **The `mcp` module is on** | A per-account opt-in. It is the only module that ships dark; you turn it on in Settings. Until then `/mcp` answers `404` for that account. | +| **The `mcp` module is on** | A per-account opt-in that ships off by default; you turn it on in Settings. Until then `/mcp` answers `404` for that account. | `APP_URL` is the same origin you already use for passkeys and invite links — for example `https://health.example`. It must be reachable by the diff --git a/docs/self-hosting/reverse-proxy.md b/docs/self-hosting/reverse-proxy.md index b75df95b1..d1df00629 100644 --- a/docs/self-hosting/reverse-proxy.md +++ b/docs/self-hosting/reverse-proxy.md @@ -3,8 +3,8 @@ The bundled `docker-compose.yml` exposes HealthLog on plain HTTP at `localhost:3000`. Production deployments front the container with a reverse proxy that terminates TLS, sets the forwarded headers the -rate-limiter trusts, and preserves the WebSocket upgrade path the -streaming Coach surface depends on. +rate-limiter trusts, and passes the Coach's Server-Sent Events stream +through unbuffered with a generous read timeout. This guide gives copy-paste blocks for five common fronts. Pick one; the env-var pairing is identical across them. @@ -21,6 +21,12 @@ Set the URL pair to the public origin (`https://your-instance.example.com`) and restart the `app` container. The image reads both at startup; no rebuild needed. +Note for bundled-compose users: `TRUST_PROXY_HOPS` is not on the +shipped `docker-compose.yml` `environment:` whitelist, and a var not on +the whitelist never reaches the container. If you need a non-default +value, add `TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-1}"` to the `app` +service's `environment:` block yourself. + ### `TRUST_PROXY_HOPS` — what to set it to `src/lib/api-response.ts` (`getClientIp`) reads the `X-Forwarded-For` @@ -56,8 +62,8 @@ your-instance.example.com { } ``` -Caddy handles the WebSocket upgrade automatically. `flush_interval -1` -disables response buffering so the streaming insight events surface +`flush_interval -1` +disables response buffering so the SSE events surface without batching delays. With Caddy as the only hop, leave `TRUST_PROXY_HOPS=1`. @@ -78,8 +84,9 @@ services: - "traefik.http.routers.healthlog.middlewares=healthlog-headers" ``` -Traefik forwards `X-Forwarded-For` by default and respects the WebSocket -upgrade. Pair with `TRUST_PROXY_HOPS=1`. If Traefik sits behind +Traefik forwards `X-Forwarded-For` by default and does not buffer +responses, so the SSE stream passes through as-is. Pair with +`TRUST_PROXY_HOPS=1`. If Traefik sits behind Cloudflare, set it to `2` so the rate-limiter reads the real client IP that Cloudflare placed in the chain. @@ -91,8 +98,9 @@ In the NPM UI: 2. **Domain Names:** `your-instance.example.com`. 3. **Scheme:** `http`; **Forward Hostname / IP:** the Docker host or internal hostname; **Forward Port:** `3000`. -4. Enable **Websockets Support** (required for streaming Coach - responses). +4. **Websockets Support** may stay on or off; the Coach stream is + SSE over plain HTTP, not WebSockets. What matters is the buffering + and timeout config in step 6. 5. **SSL tab:** request a Let's Encrypt certificate, force SSL. 6. **Advanced tab — Custom Nginx Configuration:** diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 7d9f00408..940abdc4d 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -88,10 +88,14 @@ endless spinner. ## 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/status`. +- `app` (web) healthcheck: `wget /api/health` every 30s. +- `app-worker` does not expose a port; its liveness is tracked + in-memory inside the process (`src/lib/jobs/worker-status.ts`) and + read back by `/api/admin/status`. In a web/worker split this means + the web container cannot see the separate worker container's + liveness: `/api/admin/status` only reports the worker embedded in + the container that serves the request. Watch the worker container's + own logs and restart policy instead. The web container does NOT depend on the worker, and vice versa, so neither container's startup can deadlock the other. The shared @@ -112,13 +116,17 @@ dependency is Postgres; both wait on `db: condition: service_healthy`. ## 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). +Every container that boots the HealthLog image owns a connection +budget against the configured `DATABASE_URL`. The budget is +**20 connections per container by default** and is split between the +Prisma pool and pg-boss (2 connections). The bundled compose exposes +the knob as `DB_CONNECTION_LIMIT`, baked into `DATABASE_URL` as +`connection_limit`; `DATABASE_POOL_MAX` remains a working explicit +override. Resolution order: `DB_CONNECTION_LIMIT`, then +`DATABASE_POOL_MAX`, then the `connection_limit` URL parameter, then +the default of 20. -Plan total Postgres slots as **container_count × `DATABASE_POOL_MAX`**. +Plan total Postgres slots as **container_count × budget**. A stock Postgres 16 container ships with `max_connections = 100`, so the safe envelope is: @@ -139,9 +147,10 @@ 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: +With the bundled compose, set `DB_CONNECTION_LIMIT` in `.env`; it +flows into `DATABASE_URL`'s `connection_limit`. On a hand-rolled +setup, `DATABASE_POOL_MAX` as a plain env var works the same way on +every container, web and worker alike: ```yaml # docker-compose.yml — example for a 6-container deployment @@ -176,6 +185,7 @@ tenant, starving every other dashboard query for the duration. The 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 +The implementation lives in `src/lib/db.ts → getConnectionBudget()` +(split into `getPrismaPoolMax()` and `getPgBossPoolMax()`); the 20-slot default was chosen after measuring on a 4-CPU production -container — see the inline rationale in `getPoolMax()`. +container — see the inline rationale in `src/lib/db.ts`. diff --git a/docs/self-hosting/sso.md b/docs/self-hosting/sso.md index 853446fe6..deb826f02 100644 --- a/docs/self-hosting/sso.md +++ b/docs/self-hosting/sso.md @@ -115,9 +115,11 @@ Client registration essentials only — the IdP's own docs cover the rest. (the routes return 403; hiding the buttons is not the boundary). Consequences to accept before turning it on: -- **The native iOS app cannot sign in.** It authenticates via - password/passkey only; there is no native SSO flow yet. Do not enable - `OIDC_ONLY` on a deployment with active iOS users. +- **The native iOS app needs a build that ships the SSO flow.** The app + signs in via the native OIDC handoff (`GET /api/auth/oidc/login?client=native` + with PKCE and the fixed `healthlog://` callback) or via password/passkey. + `OIDC_ONLY` works with app builds that carry the handoff; older + password/passkey-only builds are locked out. - **Existing sessions survive.** The flag gates new sign-ins, not live sessions — nobody is thrown out at flip time, including password-authenticated sessions. diff --git a/docs/self-hosting/unraid.md b/docs/self-hosting/unraid.md index 062562321..91e5c89d6 100644 --- a/docs/self-hosting/unraid.md +++ b/docs/self-hosting/unraid.md @@ -7,7 +7,8 @@ Unraid UI, but because CA templates describe a single container you install PostgreSQL separately and wire the two together. HealthLog stores all persistent state in PostgreSQL — the app container itself is -stateless (the offline geo-IP databases are baked into the image). A backup of +stateless (the offline geo-IP databases are either baked in at build time or +mounted read-only; replaceable either way, not state). A backup of the database is a backup of everything. ## What you need