mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
mobile + docs: themeColor light-mode, Coach FAB safe-area, Sonner system theme, self-hosting docs
Three hotfix-ready findings from the v1.4.34.3 mobile-deep audit
plus the first two docs A8 audit identified as missing.
Mobile-deep:
- `viewport.themeColor` was pinned to `#282a36` (dark). The
Android URL bar + iOS PWA status bar stayed dark even when the
user was on the light theme. Replaced with the dual-media-query
form: `#ffffff` under `(prefers-color-scheme: light)`,
`#282a36` under `(prefers-color-scheme: dark)`. Browser chrome
now matches the active palette.
- Coach FAB was pinned at `bottom-20` (5 rem). On notched iPhones
the home-indicator safe area is ~34 px so the FAB collided with
the bottom-nav. Moved to
`bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]` so it
floats above the inset-aware nav.
- Sonner toaster shipped `theme="dark"` hardcoded. On a light-mode
page the toast contrasted badly against the surface. Switched to
`theme="system"` so Sonner reads the resolved theme from the
root element (set pre-paint by the inline theme script in
layout.tsx) instead of pinning dark.
Self-hosting docs:
- `docs/self-hosting/getting-started.md` — step-by-step from
`git clone` to first measurement, covering the 3-secret env
setup (post-SESSION_SECRET drop), `docker compose up -d`, the
first-user-becomes-admin contract, and the next-step pointers.
- `docs/self-hosting/reverse-proxy.md` — Caddy, Traefik, Nginx
Proxy Manager, Coolify, and bare-Nginx config examples. Closes
the README link at line 107 that previously punted to the docs
site.
This commit is contained in:
1 parent
999fc257ae
commit
be7f4aa92b
5 files changed
+372
-3
No files matched your search
@@ -0,0 +1,154 @@
|
||||
# Getting started — self-host HealthLog
|
||||
|
||||
This walkthrough takes a fresh host from `git clone` to a running
|
||||
instance with a working first user. About fifteen minutes if Docker is
|
||||
already installed, longer if a TLS-fronted public hostname is in scope
|
||||
(see the reverse-proxy guide for that).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker 24+ with Compose v2.** The bundled `docker-compose.yml`
|
||||
pulls a multi-arch image from GitHub Container Registry (linux/amd64
|
||||
+ linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
|
||||
unchanged.
|
||||
- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with
|
||||
measurement history; the Apple Health import worker can briefly
|
||||
hold a 1.5 GB upload in `/tmp` while it parses.
|
||||
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
|
||||
integration endpoints you plan to enable (Withings, OpenAI, etc.).
|
||||
- **Optional but recommended:** a reverse proxy that terminates TLS
|
||||
and forwards to `http://localhost:3000` (Caddy, Traefik, Nginx
|
||||
Proxy Manager, Coolify, or bare Nginx — see `reverse-proxy.md`).
|
||||
|
||||
## 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MBombeck/HealthLog.git
|
||||
cd HealthLog
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
`.env.example` is the canonical reference for every env var the app
|
||||
reads. Anything you do not uncomment falls back to a sane default or
|
||||
disables the optional subsystem.
|
||||
|
||||
## 2. Generate the three required secrets
|
||||
|
||||
HealthLog needs three secrets before it will boot. Generate them with
|
||||
`openssl` and append to `.env`:
|
||||
|
||||
```bash
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
| Variable | Purpose | Rotation cost |
|
||||
| -------- | ------- | ------------- |
|
||||
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
|
||||
|
||||
Then open `.env` and confirm `DATABASE_URL` includes the password you
|
||||
just generated. The default template (`postgresql://healthlog:CHANGE-ME@db:5432/healthlog`)
|
||||
references the literal string `CHANGE-ME`; replace it with the value
|
||||
you put in `POSTGRES_PASSWORD`, or rewrite the URL to substitute
|
||||
explicitly.
|
||||
|
||||
## 3. Bring the stack up
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Compose starts two services:
|
||||
|
||||
- `db` — PostgreSQL 16 with a named volume for persistence.
|
||||
- `app` — HealthLog itself, listening on `localhost:3000`. The image
|
||||
runs `prisma migrate deploy` on boot so the schema lands
|
||||
automatically; subsequent restarts no-op when the database is
|
||||
already current.
|
||||
|
||||
The first boot pulls the image (a few hundred megabytes) and runs
|
||||
migrations. Once `docker compose logs -f app` shows the Next.js
|
||||
banner, the instance is live.
|
||||
|
||||
## 4. Create the first user
|
||||
|
||||
Open `http://localhost:3000` in a browser. The registration flow asks
|
||||
for a username, email, and password. **The first user that registers
|
||||
is promoted to admin automatically.** Every subsequent registration
|
||||
creates a regular user.
|
||||
|
||||
After the first user lands, you can:
|
||||
|
||||
- Open `/admin` to review the worker status, audit log, and any
|
||||
optional channels you want to configure (Telegram bot, ntfy, Web
|
||||
Push VAPID keys, AI fallback key).
|
||||
- Open `/measurements` and add a weight or blood pressure to confirm
|
||||
the data path is wired end-to-end.
|
||||
|
||||
## 5. Point the instance at a public hostname
|
||||
|
||||
Local-only is fine for a single-user trial. For anything more — and
|
||||
to unlock OAuth callbacks, webhook deliveries, and Web Push — set the
|
||||
public URL pair before restarting:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_APP_URL="https://your-instance.example.com"
|
||||
APP_URL="https://your-instance.example.com"
|
||||
```
|
||||
|
||||
Both vars must match. `NEXT_PUBLIC_APP_URL` lands in the client bundle
|
||||
at build time and drives every absolute URL the UI emits (OAuth
|
||||
redirects, share links, manifest icons). `APP_URL` is the server-side
|
||||
mirror used by background jobs that have no request context.
|
||||
|
||||
Restart the app container after editing:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The pre-built image reads both values at startup; no rebuild needed.
|
||||
|
||||
## 6. Pick a reverse-proxy
|
||||
|
||||
The bundled stack exposes plain HTTP on `localhost:3000`. Production
|
||||
deployments terminate TLS in front of the container. The
|
||||
`docs/self-hosting/reverse-proxy.md` guide carries copy-paste
|
||||
configurations for Caddy, Traefik, Nginx Proxy Manager, Coolify, and
|
||||
bare Nginx — including the `X-Forwarded-For` chain shape that the
|
||||
rate-limiter trusts and the `TRUST_PROXY_HOPS` env var that pins how
|
||||
many hops to read back.
|
||||
|
||||
## 7. Where to go next
|
||||
|
||||
| Next step | File |
|
||||
| --------- | ---- |
|
||||
| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
|
||||
| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` |
|
||||
| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` |
|
||||
| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` |
|
||||
| Withings device sync | `docs/integrations/withings.md` |
|
||||
| Apple Health `export.zip` import | `docs/integrations/apple-health.md` |
|
||||
| AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`app` container exits with a Prisma migration error.** The
|
||||
Postgres healthcheck races the migration on slow disks. Run
|
||||
`docker compose restart app` once the database has stabilised —
|
||||
migrations are idempotent.
|
||||
- **First-user registration shows "registration disabled".** The
|
||||
bundled image ships with registration enabled; the toggle lives in
|
||||
`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
|
||||
starting. Tail `docker compose logs db` to confirm Postgres came up
|
||||
cleanly.
|
||||
- **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and
|
||||
`NEXT_PUBLIC_APP_URL` both point at the public hostname, not at
|
||||
`localhost`, and restart `app` after the change.
|
||||
@@ -0,0 +1,198 @@
|
||||
# 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:
|
||||
|
||||
```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 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:
|
||||
|
||||
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. Enable **Websockets Support** (required for streaming Coach
|
||||
responses).
|
||||
5. **SSL tab:** request a Let's Encrypt certificate, force SSL.
|
||||
6. **Advanced tab — Custom Nginx Configuration:**
|
||||
|
||||
```nginx
|
||||
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:
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
+8
-1
@@ -79,7 +79,14 @@ export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#282a36",
|
||||
// Match the browser chrome (Android URL bar, iOS PWA status bar) to
|
||||
// the active palette. The hex values are the resolved background of
|
||||
// `--background` from `app/globals.css` for each theme so the bar
|
||||
// edge never seams against the page on cold paint.
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#282a36" },
|
||||
],
|
||||
};
|
||||
|
||||
// Inline script to apply theme before first paint (prevents FOUC)
|
||||
|
||||
@@ -61,7 +61,13 @@ export function LayoutCoachFab() {
|
||||
aria-hidden={tooltipActive ? true : undefined}
|
||||
tabIndex={tooltipActive ? -1 : undefined}
|
||||
className={cn(
|
||||
"fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg",
|
||||
// Sit above the 64 px bottom-nav + the iPhone home-indicator
|
||||
// safe-area inset. `calc(env(safe-area-inset-bottom,0)+5rem)`
|
||||
// keeps the FAB clear of the nav on notched iPhones (where the
|
||||
// inset is ~34 px) without forcing extra padding into the
|
||||
// layout grid.
|
||||
"fixed right-4 z-40 h-12 rounded-full px-4 shadow-lg",
|
||||
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
|
||||
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
|
||||
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
|
||||
"lg:hidden",
|
||||
|
||||
@@ -12,7 +12,11 @@ import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme="dark"
|
||||
// `theme="system"` lets Sonner match the resolved theme on the
|
||||
// root element (set pre-paint by the inline theme script in
|
||||
// `app/layout.tsx`) instead of pinning dark — on light-mode the
|
||||
// hardcoded dark toaster contrasted badly against the page.
|
||||
theme="system"
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
|
||||
Reference in new issue
Block a user