Consolidated hotfix landing the findings from a six-axis audit
covering mobile security, UX + accessibility, code quality + docs,
README + discoverability, web performance, and mobile-deep responsive
behaviour. None of the changes break the public API or the existing
data shape; every fix is additive and SAFE under the v1.4.34.x web
freeze. Full audit reports live in `.planning/round-audit-*.md`.
Security
- Password rotation now wipes API tokens + refresh tokens in the
same `$transaction` as session revocation. A stolen iOS Bearer no
longer survives a user-initiated password change or an admin reset.
- `/.well-known/` proxy bypass tightened to an explicit-allowlist
`Set`. Stale `/api/auth/codex/callback` PUBLIC_PATHS entry dropped.
- HSTS gains `preload`. Withings host removed from the global CSP
and gated to the integration's own routes.
- `getClientIp` warns once per process on `TRUST_PROXY_HOPS`/XFF
mismatch + exposes a tagged helper for tighter universal buckets.
- Service worker rejects off-origin notification-click URLs. Offline
fallback body switched to a locale-neutral icon. Manifest gains
`scope`, `id`, `display_override`.
UX + accessibility
- i18n leaks closed on Dialog/Sheet close-X, ChartSkeleton's loading
announcement, and the notifications-section breadcrumb.
- Focus rings on bottom-nav, the More overflow button, and the
mobile top-bar user menu.
- Tap-target floor: Input / NativeSelect / Select / DateTimeInput
switch to `h-11 sm:h-10`.
- Admin chip-strip auto-scroll-into-view (matches v1.4.33 settings).
- List-page subtitles visible on mobile.
- Insights empty-state CTA vocabulary normalised so the link opens
the right measurement form (was silently failing for
BODY_TEMPERATURE / BLOOD_GLUCOSE / PULSE).
Mobile-deep
- `viewport.themeColor` reacts to light/dark mode.
- TopBar + Coach FAB reserve safe-area-inset-top / -bottom for
notched iPhones.
- Sonner toaster switches to `theme="system"`.
Code quality
- `SESSION_SECRET` dropped — the env var was documented as required
but never read by code. Three secrets, not four. Self-hosters
upgrading can delete the existing line.
- `toJson<T>()` helper consolidates seven Prisma JSON-cast sites.
- `db-compat.ts` gains a header docstring naming it the
schema-bootstrap path.
- Passkey response types adopted from `@simplewebauthn/server`;
`any` + eslint-disable pragmas drop out.
- Zero-TODO CI gate added at `.github/workflows/no-todo-markers.yml`.
Documentation + discoverability
- README rework: How-it-compares matrix, Apple Health + AI Coach
promoted to standalone Key-Features bullets, status block, latest
release + GHCR badges, OpenAPI pointer, three-secret Quick Start.
- openGraph + twitter cards now carry `images` and a
`summary_large_image` Twitter card.
- GitHub repo metadata: description tightened from 290 to 156
chars + keyword-front, `homepageUrl` flipped from a personal
tenant to the demo site, six generic / PII-adjacent topics
swapped for six high-intent ones.
- Five new user-facing docs: `docs/self-hosting/getting-started.md`,
`docs/self-hosting/reverse-proxy.md`,
`docs/integrations/apple-health.md`,
`docs/integrations/withings.md`,
`docs/integrations/ai-providers.md`.
- `docs/README.md` clarifies the docs tree as internal playbooks
vs the user-facing `docs.healthlog.dev` site.
7.1 KiB
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 preserves the WebSocket upgrade path the
streaming Coach surface depends on.
This guide gives copy-paste blocks for five common fronts. Pick one; the env-var pairing is identical across them.
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.
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 (XFF ignored entirely) |
| 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 or set it to 0 and let x-real-ip
drive the IP resolution.
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
}
}
Caddy handles the WebSocket upgrade automatically. flush_interval -1
disables response buffering so the streaming insight events surface
without batching delays. With Caddy as the only hop, leave
TRUST_PROXY_HOPS=1.
Traefik
docker-compose.override.yml snippet:
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 respects the WebSocket
upgrade. 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:
-
Proxy Hosts → Add Proxy Host.
-
Domain Names:
your-instance.example.com. -
Scheme:
http; Forward Hostname / IP: the Docker host or internal hostname; Forward Port:3000. -
Enable Websockets Support (required for streaming Coach responses).
-
SSL tab: request a Let's Encrypt certificate, force SSL.
-
Advanced tab — Custom Nginx Configuration:
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_set_header Host $host; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 300s;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:
- Application → Domains: set
https://your-instance.example.com. Coolify provisions the certificate and adds the Traefik labels. - Application → Environment Variables: set
NEXT_PUBLIC_APP_URL,APP_URL, and any optional secrets (WITHINGS_*,APNS_*,LOKI_*). - Application → General → Pull policy:
always. The bundleddocker-compose.ymldeclares this explicitly (v1.4.34.2 fix) so Coolify re-checks the GHCR digest on every redeploy instead of reusing the cached:latestlayer. - Notifications → Webhook: point at
/api/internal/deploy-webhookwith theX-Deploy-Webhook-Secretheader set toDEPLOY_WEBHOOK_SECRET. The app gates the endpoint with a timing-safe compare against that env var.
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:
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.