openssl rand -base64 24 can emit URI-reserved characters (/, +, =). Since POSTGRES_PASSWORD is interpolated unescaped into DATABASE_URL, such a password breaks the connection URL and blocks boot. Switch every documented example to openssl rand -hex 32 (URL-safe, 256-bit).
7.3 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.ymlpulls 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
/tmpwhile 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 — seereverse-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 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
docker compose up -d
Compose starts two services:
db— PostgreSQL 16 with a named volume for persistence.app— HealthLog itself, listening onlocalhost:3000. The image runsprisma migrate deployon 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
/adminto 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
/measurementsand 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:
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 |
| 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
appcontainer exits with a Prisma migration error. The Postgres healthcheck races the migration on slow disks. Rundocker compose restart apponce 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. Checkdocker compose ps— theapphealthcheck polls/api/versionevery 30 seconds and will mark the container unhealthy if Postgres is still starting. Taildocker compose logs dbto confirm Postgres came up cleanly. - OAuth callbacks loop back to localhost. Confirm
APP_URLandNEXT_PUBLIC_APP_URLboth point at the public hostname, not atlocalhost, and restartappafter the change.