mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
c986efab30
commit
fa7e1ee739
9 files changed
+70
-41
No files matched your search
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user