Files
Marc-André Bombeck e8ef590af7 docs: describe the cycle calendar subscription
A how-to for subscribing from Google, Apple and Outlook and what the
feed shows; a section on the reproductive-health page saying that once
it is on, the dates also sit with the calendar provider and turning it
off does not reach those copies; a reverse-proxy note with Caddy and
Nginx examples for keeping path credentials out of access logs; and
API_TOKEN_HMAC_KEY rotation named as ending every feed link.
2026-11-06 18:03:37 +01:00

420 lines
19 KiB
Markdown

# Reverse proxy configuration
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 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.
If the instance only needs to be reachable from your own devices, none
of them is required: `tailscale serve` terminates trusted HTTPS on the
tailnet with no extra container. See
[`docs/self-hosting/tailscale.md`](tailscale.md).
## Pre-flight — env vars the proxy interacts with
| Variable | Required for | Notes |
| ------------------------ | ---------------------------------------------- | ---------------------------------------- |
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
| `TRUSTED_PROXY_CIDRS` | Which connections may report a client address | Unset: any private address; see below |
| `TRUST_CF_CONNECTING_IP` | Reading the visitor address Cloudflare sends | Off unless `1`; see the warning below |
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.
All five variables are on the shipped `docker-compose.yml`
`environment:` whitelist, so a value in `.env` reaches the container
without editing the compose file. (Before v1.37.33 the whitelist was
missing `TRUST_PROXY_HOPS`; on those versions add
`TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"` 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`
chain from the **right**, counting back `TRUST_PROXY_HOPS` entries.
This guards against a client rotating `X-Forwarded-For` per request
to defeat IP-based rate limits.
| Topology | `TRUST_PROXY_HOPS` |
| --------------------------------------------------------- | ---------------------------------- |
| App is internet-facing with no proxy | `0` (the connection's own address) |
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
| Cloudflare → your proxy → app | `2` |
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
A misconfigured count logs a one-shot warning to stderr and collapses
every anonymous caller into one shared rate-limit bucket. Match the
value to the actual hop count.
Forwarding headers are believed only from a connection whose own address
is not a public one, which is where a proxy you run connects from:
- IPv4: loopback (`127.0.0.0/8`), `0.0.0.0/8`, the private ranges
(`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), CGNAT
(`100.64.0.0/10`, Tailscale), link-local (`169.254.0.0/16`), the reserved
blocks `192.0.0.0/24` and `198.18.0.0/15`, and everything from
`224.0.0.0` up (multicast and reserved);
- IPv6: `::` and `::1`, link-local (`fe80::/10`), site-local (`fec0::/10`),
unique-local (`fc00::/7`, Tailscale), multicast (`ff00::/8`), and an
IPv4-mapped or embedded address from one of the IPv4 ranges above.
The reserved and multicast blocks are in the list because no host on the
internet can open a connection from them, so a peer there sits on a network
you run. A request that reaches HealthLog straight from a public
address carries headers its sender wrote, so HealthLog ignores them and
uses the connection's own address, and says so once in the log. The log
line also shows up when the published port is open to the internet beside
the proxy; bind it to the proxy's network or to `127.0.0.1` then.
That list is the default when `TRUSTED_PROXY_CIDRS` is unset, and it is
only as good as the network in front of the port. Read the next section
before relying on it.
### `TRUSTED_PROXY_CIDRS` — name the proxy
Set `TRUSTED_PROXY_CIDRS` to the addresses your reverse proxy connects
from, as comma-separated CIDRs (IPv4 or IPv6; a bare address counts as a
single host). Once it is set, only a connection from inside those ranges
may report the client's address. Every other connection is its own client,
whatever its address, private ones included, and the log says so once.
Why it matters: some setups make every caller look like a private address.
Docker Desktop, OrbStack, and Docker's `docker-proxy` (the userland proxy
behind a published port such as `3000:3000`) forward each connection from
the gateway address of the Docker network, which is private. On those
hosts, someone who can reach the published port directly, from the LAN or
from the internet if the port is open, passes the private-address check
and can again choose the address that every per-IP rate limit, the audit
log and the sign-in notices see, a new one per request if they like.
Naming your proxy closes that. The default port binding in
`docker-compose.yml` stays as it is (LAN installs rely on it); this
variable is the fix.
What to set:
| Where the proxy runs | `TRUSTED_PROXY_CIDRS` |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| On the same host, proxying to `127.0.0.1:3000` (bare Nginx, Caddy) | `127.0.0.1/32,::1/128` |
| In a container on a Docker network with HealthLog (Traefik, NPM, Coolify) | that network's subnet, e.g. `172.18.0.0/16` (`docker network inspect <network>` shows it under `IPAM`) |
| On another machine that connects from a public address | that machine's address, e.g. `198.51.100.7/32` |
| No proxy, or `tailscale serve` only | leave it unset |
At boot the app logs one `[proxy-trust]` line naming the rule in force:
which connections it reads forwarding headers from and how many hops deep.
A value that is not a CIDR is logged as an error, and until it is fixed no
connection may report a client address.
**A proxy that connects from a public address.** Without
`TRUSTED_PROXY_CIDRS`, such a proxy is not believed: every request through
it resolves to the proxy's own address, so all your users share one
rate-limit bucket and one person mistyping a password can hold sign-in back
for everyone. Releases before v1.46 believed any peer, so this shows up after
the upgrade. Set `TRUSTED_PROXY_CIDRS` to the proxy's address (or put the proxy
on a private network with HealthLog) and restart.
### `TRUST_CF_CONNECTING_IP` — only when the origin takes Cloudflare only
With `TRUST_CF_CONNECTING_IP=1` HealthLog takes the visitor's address from
the `CF-Connecting-IP` header, before any of the checks above: from every
connection, whatever its address, and whatever `TRUST_PROXY_HOPS` says.
Cloudflare sets that header on every request through its edge, but any
client can send it too. So the flag is safe only when nothing but
Cloudflare can reach the origin: a Cloudflare Tunnel with no published
port, or a firewall that admits only
[Cloudflare's address ranges](https://www.cloudflare.com/ips/) to the port
HealthLog or its proxy listens on (Authenticated Origin Pulls on top is
better still). If the origin also answers on its own public address, a
caller who finds it chooses the address every per-IP rate limit, the audit
log and the sign-in locations record, and changes it on every request.
Without Cloudflare in front, leave the flag unset.
`X-Real-IP` gets the same trust as `X-Forwarded-For`. It is read only
when `TRUST_PROXY_HOPS` is exactly `1` and the request carries no
`X-Forwarded-For`, which covers a single proxy that sets only
`X-Real-IP`. With `0` neither header is believed, because without a proxy
in front both come from the caller; the address the connection came from
is used instead. With `2` or more, the hop count
describes an `X-Forwarded-For` chain, so have your proxies send one.
If requests arrive with a forwarding header the setting does not let
HealthLog read, the log shows one warning naming the header and the
setting. Every anonymous caller then shares one rate-limit bucket, so one
person mistyping a password can hold sign-in back for everyone. The usual
cause is `0` behind a proxy that sends only `X-Real-IP`: set `1`.
## Caddy
`Caddyfile`:
```
your-instance.example.com {
encode zstd gzip
reverse_proxy localhost:3000 {
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up Host {host}
flush_interval -1
}
}
```
`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`.
## Traefik
`docker-compose.override.yml` snippet:
```yaml
services:
app:
labels:
- "traefik.enable=true"
- "traefik.http.routers.healthlog.rule=Host(`your-instance.example.com`)"
- "traefik.http.routers.healthlog.entrypoints=websecure"
- "traefik.http.routers.healthlog.tls.certresolver=letsencrypt"
- "traefik.http.services.healthlog.loadbalancer.server.port=3000"
- "traefik.http.middlewares.healthlog-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.routers.healthlog.middlewares=healthlog-headers"
```
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.
## Nginx Proxy Manager
In the NPM UI:
1. **Proxy Hosts → Add Proxy Host.**
2. **Domain Names:** `your-instance.example.com`.
3. **Scheme:** `http`; **Forward Hostname / IP:** the Docker host or
internal hostname; **Forward Port:** `3000`.
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:**
```nginx
proxy_read_timeout 300s;
```
Nginx Proxy Manager configures the standard forwarded headers
automatically and adds the WebSocket upgrade directives when
Websockets Support is enabled.
The 300-second read timeout matters for long-running insight
generations — anything shorter will cut streamed responses mid-flight.
Pair with `TRUST_PROXY_HOPS=1`.
## Coolify
Coolify's bundled Traefik handles most of this. The pieces that need
operator attention:
1. **Application → Domains:** set `https://your-instance.example.com`.
Coolify provisions the certificate and adds the Traefik labels.
2. **Application → Environment Variables:** set
`NEXT_PUBLIC_APP_URL`, `APP_URL`, and any optional secrets
(`WITHINGS_*`, `APNS_*`, `LOKI_*`).
3. **Application → General → Pull policy:** `always`. The bundled
`docker-compose.yml` declares this explicitly (v1.4.34.2 fix) so
Coolify re-checks the GHCR digest on every redeploy instead of
reusing the cached `:latest` layer.
4. **Notifications → Webhook:** point at `/api/internal/deploy-webhook`
with the `X-Deploy-Webhook-Secret` header set to
`DEPLOY_WEBHOOK_SECRET`. The app gates the endpoint with a
timing-safe compare against that env var.
### DO NOT set as runtime env vars
The following variables are **build-time only** — they are injected
via `--build-arg` by the `docker-publish` workflow and baked into the
shipped image. Setting them under Coolify's _Environment Variables_
panel has zero effect on the running container, and the stale value
becomes a misleading artefact next time someone audits the deploy
config:
- `NEXT_PUBLIC_APP_VERSION` — sourced from the GHCR tag at build
time. Next.js inlines the value into both the client bundle (so
the `<VersionPoller>` self-healing reload compares against the
shipped shell) and the `/api/version` server route (so the public
endpoint returns the same string). The `env: {…}` block in
`next.config.ts` short-circuits the runtime `process.env` read.
- `NEXT_PUBLIC_APP_BUILD_SHA`, `NEXT_PUBLIC_APP_BUILT_AT` — same
pattern. The CI workflow stamps the short SHA and build timestamp
into the image during `pnpm build`.
If you spot any of these in the Coolify panel, delete the entry. The
image already carries the correct value.
Coolify-Tunnel adds one hop to the chain; pair with
`TRUST_PROXY_HOPS=2`. If you front Coolify itself with Cloudflare,
bump to `3`.
## Bare Nginx
`/etc/nginx/sites-available/healthlog`:
```nginx
upstream healthlog {
server 127.0.0.1:3000;
keepalive 16;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name your-instance.example.com;
ssl_certificate /etc/letsencrypt/live/your-instance.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-instance.example.com/privkey.pem;
client_max_body_size 1600M; # Apple Health export.zip cap is 1.5 GB
location / {
proxy_pass http://healthlog;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_buffering off;
}
}
server {
listen 80;
listen [::]:80;
server_name your-instance.example.com;
return 301 https://$host$request_uri;
}
```
`client_max_body_size 1600M` is the load-bearing setting if you plan
to support Apple Health `export.zip` uploads — the app caps the
upload at 1.5 GB and Nginx will reject the body before the app sees
it otherwise. `proxy_buffering off` keeps streamed insight events
flowing without buffer pauses. Pair with `TRUST_PROXY_HOPS=1`.
## Verifying the proxy chain
Once the proxy is in front, hit `https://your-instance.example.com/api/version`
from outside the host and check `docker compose logs app` for the
request. The wide-event log line should carry a non-null client IP
that matches your real source address. A `null` IP or a single
`unknown` rate-limit bucket means `TRUST_PROXY_HOPS` is set higher
than the actual proxy hop count, or the proxy is dropping the
`X-Forwarded-For` header entirely.
The one-shot stderr warning `[getClientIp] TRUST_PROXY_HOPS=N but
X-Forwarded-For carried M entries` confirms a mismatch. Adjust the
env var down to the actual chain length and restart.
If every request logs the same client address, the proxy's own, the proxy
is not one HealthLog believes: the `[getClientIp]` warning about a
connection "from a public address" or "from outside TRUSTED_PROXY_CIDRS"
names the case. Add the proxy's address to `TRUSTED_PROXY_CIDRS`.
A `TRUST_PROXY_HOPS` value that is not a whole number resolves no client
address at all (requests keep working) and is named in the `[proxy-trust]`
boot line.
## Credentials in the URL path: keep them out of access logs
A few HealthLog URLs carry their credential in the path, because the caller
cannot send a header: the cycle calendar feed (`/api/cycle/feed/hlf_…`,
polled by Google, Apple or Outlook), the clinician share view (`/c/hls_…`)
and the provider webhooks. HealthLog scrubs these segments from its own logs
and error reports. It cannot reach your proxy's access log, which records the
full path of every request by default, so anyone who can read that log can
read a live feed link.
Either drop those requests from the access log or rewrite the path before it
is written. For Caddy, skip logging the token-bearing paths:
```caddyfile
your-instance.example.com {
@credential_paths path /api/cycle/feed/* /c/*
log_skip @credential_paths
reverse_proxy app:3000
}
```
For Nginx, log a redacted path instead of `$request`:
```nginx
map $request_uri $redacted_uri {
~^/api/cycle/feed/ /api/cycle/feed/[REDACTED];
~^/c/ /c/[REDACTED];
default $request_uri;
}
log_format redacted '$remote_addr - [$time_local] "$request_method $redacted_uri" '
'$status $body_bytes_sent "$http_user_agent"';
access_log /var/log/nginx/access.log redacted;
```
Traefik's access log has no per-path filter; turn `accessLog` off, or keep it
on a host only you can read. If a feed link has been in a log you do not
control, rotate it in the app (Cycle, Settings, Calendar subscription, Rotate
link): the old one stops working on the provider's next poll.
The feed answers with `Cache-Control: private, no-cache` and an `ETag`, so a
caching proxy in front must pass `If-None-Match` through rather than cache the
body for everyone.
## Running over plain HTTP on a LAN (no reverse proxy)
The session cookie carries the `Secure` flag by default under
`NODE_ENV=production`, which is what the bundled Docker image runs.
On a plain-HTTP origin that is not `localhost` / `127.0.0.1` / `::1`,
modern browsers silently drop a `Secure` cookie before sending the
next request — the login round-trip looks like it succeeds (the
server returns 200 + `Set-Cookie`) but the very next page load
401s and bounces back to `/auth/login`.
If you genuinely want to serve HealthLog on `http://10.0.0.42:3000`
or `http://healthlog.lan:3000` (NAS, homelab, Tailscale-only
surface) and trust the network the traffic crosses, set:
```
SESSION_COOKIE_SECURE=false
```
in your `.env`. The session, onboarding-hint, Withings OAuth state,
and Codex device-OAuth state cookies all stop emitting `Secure` and
login works over HTTP. The trade-off is explicit: the session
cookie crosses the wire unencrypted, so the network it traverses
needs to be one you control.
For the Tailscale case specifically there is a better option than
lifting the flag: `tailscale serve` gives the same surface real HTTPS
with a tailnet-trusted certificate, and `SESSION_COOKIE_SECURE` stays
unset. See [`docs/self-hosting/tailscale.md`](tailscale.md).
**Do not set this on a deployment that ever serves plain HTTP to the
open internet.** Anyone with passive network access can capture the
session cookie and impersonate the user. The right answer for any
public-facing host is the reverse proxy + Let's Encrypt path
documented above.
The default behaviour is unchanged when this variable is unset —
deployments behind an HTTPS-terminating proxy keep setting `Secure`
exactly as before.