diff --git a/.env.example b/.env.example
index 0f7b1067b..30b6af11a 100644
--- a/.env.example
+++ b/.env.example
@@ -1,37 +1,106 @@
-# Database
-DATABASE_URL="postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public"
+# =============================================================================
+# HealthLog — Environment Configuration
+# =============================================================================
+# Copy this file to .env and fill in the values.
+# $ cp .env.example .env
+#
+# Required variables: DATABASE_URL or POSTGRES_PASSWORD, SESSION_SECRET,
+# ENCRYPTION_KEY, API_TOKEN_HMAC_KEY
+#
+# Generate the three secrets with:
+# $ openssl rand -hex 32 # any UNIX shell
+# $ node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'
+#
+# All secrets must be 64-character hex strings (= 32 bytes).
+# =============================================================================
-# App
+
+# -----------------------------------------------------------------------------
+# Database
+# -----------------------------------------------------------------------------
+# When using docker-compose.yml: the app container reaches Postgres via the
+# service name "db", so the hostname is "db" — NOT "localhost".
+# When developing without Docker (`pnpm dev` against a host-installed Postgres):
+# change the hostname to "localhost" and adjust the credentials.
+DATABASE_URL="postgresql://healthlog:CHANGE-ME@db:5432/healthlog?schema=public"
+
+# Postgres password — used both by the db service AND by DATABASE_URL above.
+# Pick a strong random password, e.g. `openssl rand -base64 24`.
+POSTGRES_PASSWORD="CHANGE-ME"
+
+
+# -----------------------------------------------------------------------------
+# App URL
+# -----------------------------------------------------------------------------
+# The public URL where users reach the app. Behind a reverse proxy with TLS,
+# this looks like https://healthlog.example.com. For local dev, leave default.
NEXT_PUBLIC_APP_URL="http://localhost:3000"
APP_URL="http://localhost:3000"
+
+# Node environment: "development" for `pnpm dev`, "production" for Docker.
NODE_ENV="development"
-# Session
-SESSION_SECRET="change-me-to-a-random-64-char-hex-string"
-# Encryption (for tokens, sensitive notes)
-ENCRYPTION_KEY="change-me-to-a-random-64-char-hex-string"
+# -----------------------------------------------------------------------------
+# Required Secrets — generate with `openssl rand -hex 32`
+# -----------------------------------------------------------------------------
+# Signs session cookies. Rotating it logs out everyone.
+SESSION_SECRET="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
-# HMAC key for API token hashing
-API_TOKEN_HMAC_KEY="change-me-to-a-random-64-char-hex-string"
+# AES-256-GCM key for encrypting Withings tokens, GitHub PATs, AI provider
+# keys, and VAPID private keys at rest. Rotate carefully — old ciphertexts
+# need re-encryption.
+ENCRYPTION_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
-# Withings OAuth (M5)
+# HMAC-SHA256 key for hashing external-API Bearer tokens before storing them
+# in the database. Rotating it invalidates all issued tokens.
+API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
+
+
+# -----------------------------------------------------------------------------
+# Withings OAuth (optional — for automatic scale/BP/activity sync)
+# -----------------------------------------------------------------------------
+# Register an app at https://developer.withings.com/ and paste the credentials
+# here. Per-user OAuth credentials can also be configured in Settings.
# WITHINGS_CLIENT_ID=""
# WITHINGS_CLIENT_SECRET=""
-# WITHINGS_REDIRECT_URI=""
-# WITHINGS_WEBHOOK_SECRET="" # random secret for webhook callback URL hardening
+# WITHINGS_REDIRECT_URI="https://healthlog.example.com/api/withings/callback"
+# WITHINGS_WEBHOOK_SECRET="" # any random string — used to harden the webhook URL
-# Telegram webhook hardening (optional but recommended)
+
+# -----------------------------------------------------------------------------
+# Telegram (optional — webhook hardening)
+# -----------------------------------------------------------------------------
+# Set to verify incoming Telegram updates. The bot token itself is configured
+# per-user in Settings (encrypted at rest).
# TELEGRAM_WEBHOOK_SECRET=""
-# OpenAI (optional, user-provided keys stored encrypted)
-# No server-side key needed - users bring their own
-# Structured Logging (Wide Events)
-# LOG_LEVEL="info" # debug | info | warn | error
-# LOG_SAMPLE_RATE="1.0" # 0.0–1.0, fraction of successful requests to log
-# LOG_SLOW_THRESHOLD_MS="3000" # Requests slower than this are always logged
-# LOG_INCLUDE_STACK="true" # Include stack traces in error events
-# LOKI_ENDPOINT="" # Optional: Loki Push API (e.g. http://loki:3100)
-# LOKI_USERNAME="" # Optional: Loki basic auth username
-# LOKI_PASSWORD="" # Optional: Loki basic auth password
+# -----------------------------------------------------------------------------
+# AI Provider (optional)
+# -----------------------------------------------------------------------------
+# Users bring their own API keys via Settings. No server-side key needed by
+# default. The admin can configure a fallback key in the Admin Panel.
+
+
+# -----------------------------------------------------------------------------
+# Structured Logging — Wide Events (optional, sensible defaults)
+# -----------------------------------------------------------------------------
+# Levels: debug | info | warn | error
+# LOG_LEVEL="info"
+
+# Sample rate for successful requests (0.0 = sample nothing, 1.0 = log all).
+# Errors and slow requests are ALWAYS logged regardless of this setting.
+# LOG_SAMPLE_RATE="1.0"
+
+# Slow-request threshold in milliseconds — anything slower is always kept.
+# LOG_SLOW_THRESHOLD_MS="3000"
+
+# Include stack traces on error events. Disable in environments where logs
+# leave the host unencrypted.
+# LOG_INCLUDE_STACK="true"
+
+# Optional: ship Wide Events to Loki. Leave blank for stdout-only logging.
+# LOKI_ENDPOINT="http://loki:3100"
+# LOKI_USERNAME=""
+# LOKI_PASSWORD=""
diff --git a/README.md b/README.md
index 554daeedf..250c930a9 100644
--- a/README.md
+++ b/README.md
@@ -67,21 +67,24 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
## Quick Start
+Plan ~5 minutes for a working install. The bundled `docker-compose.yml` builds the app from source — pre-built images on GHCR are coming in a separate release.
+
```bash
git clone https://github.com/MBombeck/HealthLog.git
cd HealthLog
cp .env.example .env
```
-Generate the required secrets:
+Generate the four required secrets and paste them into `.env`:
```bash
-openssl rand -hex 32 # SESSION_SECRET
-openssl rand -hex 32 # ENCRYPTION_KEY
-openssl rand -hex 32 # API_TOKEN_HMAC_KEY
+echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
+echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
+echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
+echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
```
-Add them to `.env`, then:
+Then bring the stack up:
```bash
docker compose up -d
@@ -89,25 +92,27 @@ docker compose up -d
Open **http://localhost:3000**. The first registered user becomes admin.
+> Behind a reverse proxy (Caddy / Traefik / Nginx) for TLS, set `NEXT_PUBLIC_APP_URL` and `APP_URL` to your public URL in `.env` before starting. See [Self-Hosting → Reverse Proxy](https://docs.healthlog.dev/self-hosting/reverse-proxy/) for examples.
+
---
## Tech Stack
-| Layer | Technology |
-|-------|-----------|
-| Framework | Next.js 16 (App Router, React Server Components) |
-| Language | TypeScript (strict mode) |
-| Database | PostgreSQL 16 + Prisma 7 (22 models) |
-| Job Queue | pg-boss 12 (reminders, insights, backups) |
-| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
-| Charts | Recharts 3 |
-| Data Fetching | TanStack Query 5 |
-| Forms | React Hook Form 7 + Zod 4 |
-| Auth | SimpleWebAuthn 13, Argon2id |
-| Notifications | Telegram Bot API, ntfy, Web Push (VAPID) |
-| PDF | jsPDF (client-side generation) |
-| Testing | Vitest 4 |
-| Deployment | Docker (multi-stage Alpine) |
+| Layer | Technology |
+| ------------- | ------------------------------------------------- |
+| Framework | Next.js 16 (App Router, React Server Components) |
+| Language | TypeScript (strict mode) |
+| Database | PostgreSQL 16 + Prisma 7 (23 models) |
+| Job Queue | pg-boss 12 (reminders, insights, backups) |
+| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
+| Charts | Recharts 3 |
+| Data Fetching | TanStack Query 5 |
+| Forms | React Hook Form 7 + Zod 4 |
+| Auth | SimpleWebAuthn 13, Argon2id |
+| Notifications | Telegram Bot API, ntfy, Web Push (VAPID) |
+| PDF | jsPDF (client-side generation) |
+| Testing | Vitest 4 |
+| Deployment | Docker (multi-stage Alpine) |
---
@@ -130,23 +135,24 @@ HealthLog is designed for people who take data ownership seriously.
### Required
-| Variable | Description |
-|----------|-------------|
-| `DATABASE_URL` | PostgreSQL connection string |
-| `SESSION_SECRET` | 64-char hex string for session signing |
-| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
-| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
+| Variable | Description |
+| -------------------- | ------------------------------------------------------------- |
+| `POSTGRES_PASSWORD` | Password for the bundled Postgres service (Docker Compose) |
+| `DATABASE_URL` | PostgreSQL connection string (uses `POSTGRES_PASSWORD` above) |
+| `SESSION_SECRET` | 64-char hex string for session signing |
+| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
+| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
### Optional
-| Variable | Description |
-|----------|-------------|
-| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
-| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
-| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
-| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
-| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
-| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
+| Variable | Description |
+| ------------------------- | ---------------------------------------------------- |
+| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
+| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
+| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
+| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
+| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
+| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URLs are configured in the **Admin Panel** and stored encrypted in the database.
@@ -205,60 +211,60 @@ All mutations require authentication via session cookie. External ingest uses Be
Health Data
-| Method | Endpoint | Description |
-|--------|----------|-------------|
-| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
-| `POST` | `/api/measurements` | Create measurement |
-| `DELETE` | `/api/measurements/:id` | Delete measurement |
-| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
-| `GET` | `/api/export` | Export as CSV or JSON |
-| `POST` | `/api/import` | Import from JSON |
-| `POST` | `/api/doctor-report` | Aggregated data for PDF |
+| Method | Endpoint | Description |
+| -------- | ----------------------- | ----------------------------------------- |
+| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
+| `POST` | `/api/measurements` | Create measurement |
+| `DELETE` | `/api/measurements/:id` | Delete measurement |
+| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
+| `GET` | `/api/export` | Export as CSV or JSON |
+| `POST` | `/api/import` | Import from JSON |
+| `POST` | `/api/doctor-report` | Aggregated data for PDF |
Mood
-| Method | Endpoint | Description |
-|--------|----------|-------------|
-| `GET` | `/api/mood-entries` | List mood entries |
-| `POST` | `/api/mood-entries` | Create mood entry |
-| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
-| `GET` | `/api/mood/analytics` | Mood trend analytics |
-| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook |
+| Method | Endpoint | Description |
+| -------- | ----------------------------------- | -------------------- |
+| `GET` | `/api/mood-entries` | List mood entries |
+| `POST` | `/api/mood-entries` | Create mood entry |
+| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
+| `GET` | `/api/mood/analytics` | Mood trend analytics |
+| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook |
Medications
-| Method | Endpoint | Description |
-|--------|----------|-------------|
-| `GET` | `/api/medications` | List all medications |
-| `POST` | `/api/medications` | Create medication |
-| `PUT` | `/api/medications/:id` | Update medication |
-| `DELETE` | `/api/medications/:id` | Delete medication |
-| `POST` | `/api/medications/:id/intake` | Log intake event |
-| `GET` | `/api/medications/:id/compliance` | Compliance stats |
-| `POST` | `/api/ingest/medication` | External intake (Bearer) |
+| Method | Endpoint | Description |
+| -------- | --------------------------------- | ------------------------ |
+| `GET` | `/api/medications` | List all medications |
+| `POST` | `/api/medications` | Create medication |
+| `PUT` | `/api/medications/:id` | Update medication |
+| `DELETE` | `/api/medications/:id` | Delete medication |
+| `POST` | `/api/medications/:id/intake` | Log intake event |
+| `GET` | `/api/medications/:id/compliance` | Compliance stats |
+| `POST` | `/api/ingest/medication` | External intake (Bearer) |
Auth and Integrations
-| Method | Endpoint | Description |
-|--------|----------|-------------|
-| `POST` | `/api/auth/register` | Create account |
-| `POST` | `/api/auth/login` | Password login |
-| `POST` | `/api/auth/logout` | Destroy session |
-| `GET` | `/api/auth/me` | Current user profile |
-| `POST` | `/api/auth/passkey/*` | WebAuthn flows |
-| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
-| `POST` | `/api/insights/generate` | Regenerate AI insights |
-| `GET` | `/api/gamification/achievements` | Achievement progress |
-| `GET` | `/api/health` | Docker health check |
+| Method | Endpoint | Description |
+| ------ | -------------------------------- | ----------------------- |
+| `POST` | `/api/auth/register` | Create account |
+| `POST` | `/api/auth/login` | Password login |
+| `POST` | `/api/auth/logout` | Destroy session |
+| `GET` | `/api/auth/me` | Current user profile |
+| `POST` | `/api/auth/passkey/*` | WebAuthn flows |
+| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
+| `POST` | `/api/insights/generate` | Regenerate AI insights |
+| `GET` | `/api/gamification/achievements` | Achievement progress |
+| `GET` | `/api/health` | Docker health check |
@@ -266,16 +272,16 @@ All mutations require authentication via session cookie. External ingest uses Be
## Integrations
-| Integration | Setup | Purpose |
-|-------------|-------|---------|
-| **Withings** | Env vars | Auto-sync weight, BP, and activity |
-| **Telegram** | Admin Panel | Medication reminders with inline buttons |
-| **ntfy** | User Settings | Self-hosted push notifications |
-| **Web Push** | Admin Panel | Browser-native VAPID notifications |
-| **OpenAI** | User Settings | AI health insights (BYOK) |
-| **moodLog.app** | User Settings | Mood tracking sync |
-| **Umami** | Admin Panel | Privacy-friendly analytics |
-| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
+| Integration | Setup | Purpose |
+| --------------- | ------------- | ---------------------------------------- |
+| **Withings** | Env vars | Auto-sync weight, BP, and activity |
+| **Telegram** | Admin Panel | Medication reminders with inline buttons |
+| **ntfy** | User Settings | Self-hosted push notifications |
+| **Web Push** | Admin Panel | Browser-native VAPID notifications |
+| **OpenAI** | User Settings | AI health insights (BYOK) |
+| **moodLog.app** | User Settings | Mood tracking sync |
+| **Umami** | Admin Panel | Privacy-friendly analytics |
+| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
---
diff --git a/docker-compose.yml b/docker-compose.yml
index b7f4355d3..c990c15bc 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,10 +4,10 @@ services:
context: .
dockerfile: Dockerfile
container_name: healthlog-app
- expose:
- - "3000"
+ ports:
+ - "3000:3000"
environment:
- DATABASE_URL: "postgresql://healthlog:healthlog@db:5432/healthlog?schema=public"
+ DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/healthlog?schema=public"
NODE_ENV: production
SESSION_SECRET: "${SESSION_SECRET:?SESSION_SECRET is required}"
ENCRYPTION_KEY: "${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}"
@@ -24,7 +24,7 @@ services:
container_name: healthlog-db
environment:
POSTGRES_USER: healthlog
- POSTGRES_PASSWORD: healthlog
+ POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
POSTGRES_DB: healthlog
volumes:
- pgdata:/var/lib/postgresql/data