mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The client-IP resolver trusted one forwarding hop by default and read the rightmost X-Forwarded-For entry. Next fills that header with the socket's address only when the request carries none, so a caller reaching the published port directly could send its own and choose a fresh address per request: every per-IP limit (sign-in, second factor, passkey, share unlock, webhooks) became per-request, and sessions, the audit log and sign-in notices recorded the chosen address. The server now writes each connection's own peer address into an internal header from Node's http.server.request.start diagnostics channel, installed at boot and overwriting anything a caller sent under that name. The resolver reads forwarding headers only when that peer is loopback, private, unique-local or CGNAT, where a reverse proxy sits; a public peer is its own client address, and a log line says once that its forwarding headers were ignored. With TRUST_PROXY_HOPS=0 the peer is used instead of no address, so a directly exposed host no longer shares one bucket across all anonymous callers. Proxies on the same host, Docker network or tailnet need no change.
296 lines
12 KiB
Markdown
296 lines
12 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 |
|
|
|
|
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 three 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 that could come
|
|
from a proxy: loopback, a private or unique-local network, or CGNAT (a
|
|
Tailscale address). 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. A proxy on
|
|
the same host or Docker network connects from a private address, so the
|
|
usual setups above need nothing. If your proxy reaches HealthLog over a
|
|
public address, put it on a private network with HealthLog instead (or, for
|
|
Cloudflare straight to the origin, use `TRUST_CF_CONNECTING_IP=1`). 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.
|
|
|
|
`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.
|
|
|
|
## 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.
|