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
This commit is contained in:
Marc-André Bombeck committed 2026-08-27 12:21:27 +02:00
1 parent c986efab30
commit fa7e1ee739
9 files changed
+70 -41

No files matched your search

+2 -2
View File
@@ -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
+5 -5
View File
@@ -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
+9 -3
View File
@@ -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.
+5 -3
View File
@@ -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
+1 -1
View File
@@ -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
+16 -8
View File
@@ -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:**
+25 -15
View File
@@ -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`.
+5 -3
View File
@@ -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.
+2 -1
View File
@@ -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