mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
420 lines
19 KiB
Markdown
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.
|