Files
HealthLog/docs/self-hosting/getting-started.md
T
Marc-André Bombeck a8b0365ca5 fix(auth): carry a generated secret in the session cookie, not the row id
The cookie was the Session row's cuid. cuid is built for collision-
resistant identity across distributed writers, not for unguessability —
it encodes a millisecond timestamp and a process-local counter and never
touches a CSPRNG. A session cookie is a bearer credential and has to be
unguessable, so it now carries 32 random bytes and only the HMAC of that
value is stored, matching how api_tokens and refresh_tokens already hold
their secrets.

Compatibility is passive, and deliberately so. A backfill cannot help:
the cookie already sitting in a browser is the cuid, and nothing we
generate server-side can be pushed into it — backfilling would sign
everyone out at deploy. Upgrading a row in place on first use loses to
the parallel requests a single page load fires: each mints its own
secret, one write wins, and the losers cannot re-emit the winner's
secret because only a hash is stored, so the browser can end up holding
a value the row does not honour. Instead, rows minted before the column
keep a NULL token_hash and stay resolvable by id, and are denied the
sliding-expiry extension so they drain within the 30-day session
lifetime. Every login from this release forward is on a secret at once,
and an id stops authenticating the moment its row holds one.

Note for operators: API_TOKEN_HMAC_KEY now also keys session cookies, so
rotating it signs browser sessions out as well as invalidating tokens.
It is already Core-required, so no deployment gains a new prerequisite.
2026-07-19 11:40:56 +02:00

12 KiB

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.). The opt-in environmental-context module additionally reaches api.open-meteo.com for weather and daylight; it stays off until you enable it and set a home location, and every outbound call goes through the SSRF-guarded egress wrapper.
  • 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

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:

echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .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 and web session cookies before storage Invalidates every issued hlk_* token and signs out every browser session

You do not edit DATABASE_URL on the bundled docker-compose.yml stack: compose constructs the connection string inside the app service from ${POSTGRES_PASSWORD} (its environment: block overrides any DATABASE_URL in .env), so the openssl line above is the only password step. DATABASE_URL only matters if you run a bare Node process (pnpm dev against a host Postgres) or a custom compose that does not derive it — in that case uncomment and set DATABASE_URL explicitly in .env (see the commented example there).

3. Bring the stack up

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.

Close public registration if you expose the instance to the internet. Registration stays open after the first admin so you can invite the rest of your household — but on a publicly reachable host that means strangers can still sign up. Once everyone who needs an account has one, turn it off in /admin → registration toggle (or keep it open and hand out invite links instead). The admin settings show a reminder underneath the toggle while registration is open.

No email is configured out of the box — a deliberate first-boot simplicity win, but it also means password reset has no self-service path until you set SMTP (see docs/self-hosting/notifications.md). If you are the sole admin, save your password in a manager now: a locked-out solo admin with no SMTP recovers only via the CLI script in docs/ops/password-reset.md.

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:

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:

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.

Build-time feature toggles

A small number of toggles are NEXT_PUBLIC_* variables: the framework inlines them into the client bundle at build time, so the pre-built GHCR image cannot read them at runtime. Setting them in .env or the compose environment: block has no effect — they only take hold when you build your own image. They are intentionally absent from the compose runtime whitelist for that reason.

Variable Default Effect when set
NEXT_PUBLIC_DASHBOARD_SNAPSHOT on The dashboard hydrates every above-the-fold tile from the single GET /api/dashboard/snapshot cell so the whole strip shares one completion moment. Build with NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false to fall back to the legacy four-cell path.

To flip one, rebuild the image with the variable exported:

NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
# or pass it as a Dockerfile build ARG / Docker build --build-arg

7. Where to go next

Next step File
TLS + reverse-proxy configuration docs/self-hosting/reverse-proxy.md
Push notifications — Web Push, Telegram, ntfy, webhook, email, APNs docs/self-hosting/notifications.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
WHOOP device sync (per-user BYO-keys) docs/integrations/whoop.md
Google Health sync — Fitbit & Pixel (per-user BYO-keys) docs/integrations/google-health.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.
  • An env var in .env seems ignored / a feature stays "off". The compose environment: block is a whitelist — a variable set in .env only reaches the container if docker-compose.yml lists it under environment:. Vars not listed never propagate, even with ${VAR} substitution at compose-up. If a setting looks configured but is silently dead, check the var is in that block (add it if you run a custom compose); the supported alternative for most secrets (VAPID, GitHub token, GlitchTip DSN) is the admin panel, which stores them in the database and sidesteps the whitelist entirely.
  • A new image doesn't pick up / :latest looks stale. docker-compose.yml sets pull_policy: always, which is load-bearing: without it Docker re-uses the cached :latest digest and silently skips the registry round-trip on compose up. If you pin a custom compose, keep pull_policy: always on the app service, and verify the running build with GET /api/version (it returns version + buildSha).

Backup and restore

Your database is the only stateful piece — back it up, and back up the key that decrypts it.

Local snapshot (the common single-box case). Dump the db service with pg_dump:

# The bundled db service uses user `healthlog` and database `healthlog`.
docker compose exec -T db pg_dump -U healthlog healthlog \
  | gzip > healthlog-$(date +%F).sql.gz

Restore into a fresh database the same way:

gunzip -c healthlog-2026-01-01.sql.gz \
  | docker compose exec -T db psql -U healthlog healthlog

Back up your ENCRYPTION_KEYS / ENCRYPTION_KEY alongside the dump. HealthLog encrypts sensitive columns at rest (AES-256-GCM). A database dump without the encryption key is unrecoverable — the encrypted data cannot be read back. Store the key (and any retired keys still in the rotation map) somewhere separate from the dump but equally durable. Losing the key means losing the encrypted data, full stop.

For off-host encrypted backups to S3/R2/B2 see docs/ops/backup-restore.md; for rotating the encryption key safely (keep the old key until zero rows remain on it) see docs/ops/encryption-key-rotation.md.