# ============================================================================= # HealthLog — Environment Configuration # ============================================================================= # Copy this file to .env and fill in the values. # $ cp .env.example .env # # This is a short starter. Every variable the app reads, with what it does, # is documented in .env.production.example. # # Required variables: DATABASE_URL or POSTGRES_PASSWORD, # ENCRYPTION_KEY, API_TOKEN_HMAC_KEY # # Generate the two 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). # ============================================================================= # ----------------------------------------------------------------------------- # Database # ----------------------------------------------------------------------------- # Bundled docker-compose.yml path (the common case): you do NOT set DATABASE_URL # here. Compose CONSTRUCTS it inside the `app` service from POSTGRES_PASSWORD # (see docker-compose.yml — the `environment:` block wins over any value in this # file), so a DATABASE_URL line here is ignored for that stack. Set only # POSTGRES_PASSWORD below and compose wires the connection string for you. # # Bare-Node / custom-compose path (`pnpm dev` against a host Postgres, or your # own compose that does NOT derive DATABASE_URL): uncomment DATABASE_URL and set # it explicitly. Hostname is "db" on the bundled compose network, or # "localhost" for a host-installed Postgres. # DATABASE_URL="postgresql://healthlog:YOUR_PASSWORD@localhost:5432/healthlog?schema=public" # Postgres password for the bundled `db` service — and the value compose splices # into DATABASE_URL. The quick-start appends a generated one: # echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env # Left commented here so that append does not create a duplicate key. Uncomment # and set it directly instead if you prefer to edit the file by hand. # The container runs the app as `healthlog_app__`, a database # role it creates on every start that reads and writes rows but cannot change # the schema. The # migrations keep the DATABASE_URL user. "off" keeps the app on that user, if # you manage database roles yourself (docs/self-hosting/database-role.md). # DB_APP_ROLE="off" # POSTGRES_PASSWORD="generate-with-openssl-rand-hex-32" # ----------------------------------------------------------------------------- # 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 cookie `Secure` flag (v1.5.1). # Defaults to NODE_ENV === "production" — the safe choice for any # deployment terminated by HTTPS (Caddy / Traefik / Nginx in front). # Set to `false` if you intentionally serve plain HTTP on a LAN, VPN, # or Tailscale-only surface — the browser otherwise drops the session # cookie on HTTP origins and login round-trips fail silently. NEVER # set to `false` for a deployment that serves plain HTTP to the open # internet; the session cookie crosses the wire in cleartext. # SESSION_COOKIE_SECURE="false" # Where this server's environment is configured: compose, truenas, coolify, # unraid or portainer. Only picks which instructions the admin's "Back up # your encryption key" step shows first; unset means compose. # HEALTHLOG_PLATFORM="compose" # Start-up encryption key check: enforce (default) refuses to serve when the # key does not open the stored data; warn only logs it and serves anyway. A # last resort for a refusal you are certain is false (a first start, with no # recorded key to reset). It disables a safety check: with a wrong key every # encrypted value fails and new rows are written under that key. # ENCRYPTION_KEY_CHECK="enforce" # ----------------------------------------------------------------------------- # Required Secrets — generate with `openssl rand -hex 32` # ----------------------------------------------------------------------------- # 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" # 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 enter its client id # and secret in Settings -> Integrations -> Withings. There is no environment # variable for them; only the redirect override and webhook secret live here. # WITHINGS_REDIRECT_URI="https://healthlog.example.com/api/withings/callback" # WITHINGS_WEBHOOK_SECRET="" # any random string — used to harden the webhook URL # ----------------------------------------------------------------------------- # 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="" # ----------------------------------------------------------------------------- # Notification targets on a private network (optional) # ----------------------------------------------------------------------------- # Webhook (Gotify, Home Assistant, n8n) and ntfy targets on your own network, # comma-separated exact origins, e.g. "https://gotify.example.com,http://ntfy.lan:8080". # A target resolving to a private address is otherwise refused at send time. # Link-local, metadata and unspecified addresses stay refused even when listed. # See docs/self-hosting/notifications.md. # NOTIFICATION_PRIVATE_ORIGINS="" # ----------------------------------------------------------------------------- # Document picker: Paperless-ngx and Papra -- optional # ----------------------------------------------------------------------------- # The Paperless-ngx and Papra instances people may connect to pick documents # from, comma-separated exact origins (scheme://host[:port], no path), e.g. # "https://paperless.example.com,http://papra.lan:1221". Unset, the document # picker is switched off. Only a listed origin is ever contacted, public or # private; requests are pinned to it, redirects are not followed, and # link-local, metadata and unspecified addresses stay refused even when # listed. A malformed entry is logged once and grants nothing. # See docs/self-hosting/document-sources.md. # DOCUMENT_SOURCE_ORIGINS="" # ----------------------------------------------------------------------------- # 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. # AI endpoints on your own network (optional). An AI base URL on a private # address is refused unless its exact origin is listed here, comma-separated # (e.g. "http://ollama.lan:11434,http://10.0.0.5:4000") — for a self-hosted # Ollama, LM Studio or LiteLLM gateway. Link-local, metadata and unspecified # addresses stay refused even when listed. See docs/integrations/ai-providers.md. # AI_PRIVATE_ORIGINS="" # Older form, kept for existing installs: an exact host list still works # (e.g. "ollama.lan,10.0.0.5"). "true" is deprecated: it now covers only the # admin AI key and admin accounts' own AI settings, and will be removed. # ALLOW_LOCAL_AI_PRIVATE_HOSTS="" # Daily-briefing output-token ceiling (optional). Default 2500, clamped to # 500-8000. Raise it when a verbose model's briefing JSON gets cut off # ("AI response was cut off"). See docs/integrations/ai-providers.md. # INSIGHTS_MAX_TOKENS="" # Maximum number of comprehensive-insight generations a single user can # trigger per hour (POST /api/insights/generate). Default 10. The 24h # DB cache short-circuits this for read traffic; only force=true and # expired-cache regenerations consume tokens. Lower this when running # on a tight LLM budget. # INSIGHTS_RATE_LIMIT_PER_HOUR="10" # ----------------------------------------------------------------------------- # 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" # Heap diagnostics, off by default. HEALTHLOG_HEAP_SNAPSHOT=1 writes ONE heap # snapshot the first time the heap reaches 85 % of its limit, into # HEALTHLOG_DIAG_DIR (default /tmp). A snapshot contains decrypted health data # and keys; enable it only to chase a crash and delete the file afterwards. # See docs/ops/heap-diagnostics.md. # HEALTHLOG_HEAP_SNAPSHOT="1" # HEALTHLOG_DIAG_DIR="/tmp" # Optional: ship Wide Events to Loki. Leave blank for stdout-only logging. # LOKI_ENDPOINT="http://loki:3100" # LOKI_USERNAME="" # LOKI_PASSWORD="" # ----------------------------------------------------------------------------- # Reverse-proxy trust (optional, defaults match a single-proxy stack) # ----------------------------------------------------------------------------- # When HealthLog sits behind Cloudflare (Tunnel, Workers, classic proxy), # the visitor IP arrives in the `CF-Connecting-IP` header — the upstream # `X-Forwarded-For` chain stops at the Coolify / Caddy loopback and the # admin sign-in geo lookup has no signal to resolve from. # # Set this to "1" ONLY when every public request to HealthLog terminates # at Cloudflare (Tunnel, Workers, classic proxy) before it reaches your # origin, and the origin accepts connections from Cloudflare alone (a # Tunnel, or a firewall that admits only Cloudflare's address ranges): the # header is believed from every connection, before any other check. # Deployments behind nginx, Caddy, Traefik, an AWS ALB, or any # direct-to-Next.js exposure MUST leave this off. # # Security implications when the flag is mis-set: # - `cf-connecting-ip` becomes the IP-rate-limit bucket key for # login / chat / coach. An attacker who can set the header rotates # it per request and defeats every IP-keyed throttle. # - The same value lands in the `audit_logs.ipAddress` column for # every `measurement.create`, `medication.intake`, sign-in, and # admin action. A misconfigured flag lets any caller forge the # IP recorded against their actions across the audit trail. # - The admin sign-in overview's "Standort" column reads the same # value via geo-backfill; a malicious caller can poison the column # with a chosen IP per request. # # Code-level validation is strict (only "=== '1'" enables, value is # regex- and length-bounded), but the gating decision is up to the # operator — there is no way for the server to detect whether the flag # matches the real deployment topology. When in doubt, leave it off. # Number of trusted reverse-proxy hops in front of the app. The client IP is # read that many entries from the right of the X-Forwarded-For chain. Set 0 # for a directly-exposed host; 1 (the default when unset) behind a single # reverse proxy. X-Real-IP is read only at 1. Forwarding headers are read only # from a connection whose own address is not public (loopback, private, CGNAT, # unique-local, link-local, reserved or multicast; the full list is in # docs/self-hosting/reverse-proxy.md). A wrong value makes the app # refuse the headers, log one warning, and share one rate-limit bucket across # all anonymous callers. # TRUST_PROXY_HOPS="1" # The addresses the reverse proxy connects from (comma-separated CIDRs). Set, # only those connections may report the client's address; unset, any private # address may, which behind Docker Desktop, OrbStack or docker-proxy includes # every caller of the published port. Same host: 127.0.0.1/32,::1/128; Docker # network: the proxy's subnet. See docs/self-hosting/reverse-proxy.md. # TRUSTED_PROXY_CIDRS="" # TRUST_CF_CONNECTING_IP="" # ----------------------------------------------------------------------------- # Process split (optional, single-container deploys ignore this) # ----------------------------------------------------------------------------- # `all` — single container runs HTTP + reminder worker (default, legacy). # `web` — HTTP only; pair with a separate `app-worker` service. # `worker`— pg-boss only; rejects HTTP traffic with a 503. # See docs/self-hosting/scaling.md for the split-stack guide. # HEALTHLOG_PROCESS_TYPE="all" # ----------------------------------------------------------------------------- # Encryption-key versioning (optional — for at-rest key rotation) # ----------------------------------------------------------------------------- # JSON map of key id → 64-hex-char or base64 32-byte key. When set, it # supersedes ENCRYPTION_KEY for new writes; old rows continue to decrypt under # their original id. ENCRYPTION_ACTIVE_KEY_ID picks which entry encrypt() uses. # Run `pnpm tsx scripts/rotate-encryption-key.ts ` to re-encrypt every # existing row under the new key. See docs/ops/encryption-key-rotation.md. # ENCRYPTION_KEYS='{"v1":"","v2":""}' # ENCRYPTION_ACTIVE_KEY_ID="v2" # ----------------------------------------------------------------------------- # Off-host backup (optional — daily encrypted dumps to an S3-compatible bucket) # ----------------------------------------------------------------------------- # Required ALL-OR-NONE: setting any of these without setting the rest disables # the backup silently. The backup runs at 02:30 Europe/Berlin via pg-boss cron. # Restore with `pnpm tsx scripts/restore-backup.ts `. # See docs/ops/backup-restore.md for the format spec + step-by-step. # # The worker never deletes a BACKUP object -- its grant is PutObject, GetObject # and AbortMultipartUpload -- so BACKUP_RETENTION_DAYS is the number you set # the bucket's own lifecycle rule to, not something the worker enforces. The # one key it does delete is its own connection test's `_healthcheck/.bin`, # and that delete is best-effort: without DeleteObject on that prefix every # Test-connection press leaves one 1-byte orphan behind, silently. # # BACKUP_S3_ENDPOINT="https://.r2.cloudflarestorage.com" # BACKUP_S3_BUCKET="healthlog-backups" # BACKUP_S3_ACCESS_KEY="" # BACKUP_S3_SECRET_KEY="" # BACKUP_S3_REGION="auto" # "auto" for Cloudflare R2; real region for AWS S3 # BACKUP_RETENTION_DAYS="30" # the number for your bucket lifecycle rule (see above) # BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY # BACKUP_ENCRYPTION_PREVIOUS_KEYS="" # retired off-host keys, comma-separated, read-only during a rotation # ----------------------------------------------------------------------------- # Database dumps (optional, on by default) # ----------------------------------------------------------------------------- # The whole-database dump that rebuilds a lost server: nightly, and before # every update that brings migrations. Written to DB_BACKUP_DIR with a # manifest beside it (row counts, key fingerprints, versions); with the # off-host bucket above set, each nightly dump is also uploaded under # `database/`, sealed with BACKUP_ENCRYPTION_KEY. Local dumps are not # encrypted: they sit beside the database volume, in the same trust zone. # Restore: `docker compose run --rm db-restore latest`. See # docs/ops/disaster-recovery.md. # # Set "false" to stop the nightly dump. # DB_BACKUP_ENABLED="true" # Where dumps are written. The bundled compose file mounts the `backups` # volume here; change it only together with that mount. In a container a # directory that is not on a mount of its own sits in the container's layer # and is lost at the next update, so no dump is kept there. # DB_BACKUP_DIR="/var/lib/healthlog/backups" # Five-field cron, in Europe/Berlin time like every scheduled job. # DB_BACKUP_SCHEDULE="30 1 * * *" # Nightly dumps kept (at least 1). The last two pre-migration dumps are kept # besides. # DB_BACKUP_KEEP="7" # Set "false" to start an update without the dump before its migrations. # DB_BACKUP_BEFORE_MIGRATE="true" # ----------------------------------------------------------------------------- # Stored backups in the database (optional) # ----------------------------------------------------------------------------- # The largest weekly or uploaded backup of one account the app keeps, in # megabytes of encrypted copy. Default 2048. It does not depend on the app's # memory: a copy is written and read in pieces of about a megabyte. The limit # only stops a runaway copy from filling the database volume, which holds the # old and the new copy side by side while a backup runs. An account with 2.6 # million readings stores a copy of about 100 MB. # BACKUP_MAX_STORED_MB="2048" # ----------------------------------------------------------------------------- # Audit-trail retention (optional) # ----------------------------------------------------------------------------- # How long rows in `audit_logs` are kept. Default 365 days; the floor is 7 and # anything below it is ignored, so a window set in seconds by mistake cannot # empty a fresh table. A daily pg-boss job does the pruning. # # The number is not internal. Settings -> Shared access states it under the # activity list, and the dialog that ends somebody's access says how long # "who entered what" stays answerable. Both read the value the server # resolved, so an instance running 90 says 90 everywhere. # AUDIT_LOG_RETENTION_DAYS="365" # ----------------------------------------------------------------------------- # APNs — Apple Push Notification service (v1.4.23+, optional) # ----------------------------------------------------------------------------- # All four core APNS_* vars must be set together for APNs to fan out to iOS # devices. Leaving them unset disables the channel silently — the dispatcher # falls back to Telegram → ntfy → Web Push as before. # # Provision an APNs auth key (.p8) at https://developer.apple.com/account/ # under Certificates, Identifiers & Profiles → Keys → APNs. # # APNS_KEY (preferred for 12-factor / Coolify env-block) and APNS_KEY_FILE # (filesystem path) are mutually exclusive — pick one. APNS_KEY accepts the # multi-line PEM body with literal newlines OR `\n` escapes. # APNS_KEY_ID="" # 10-char Key ID from Apple Developer (e.g. ABCDE12345) # APNS_TEAM_ID="" # 10-char Team ID from Apple Developer # APNS_BUNDLE_ID="" # iOS app bundle id (e.g. dev.healthlog.ios) # APNS_KEY="" # APNs auth key as PEM body (multi-line OK with \n escapes); preferred over APNS_KEY_FILE # APNS_KEY_FILE="" # Alternate: filesystem path to .p8 file # APNS_PRODUCTION="" # "true" to force the production gateway; default = mirror Device.apnsEnvironment # ----------------------------------------------------------------------------- # Auto-deploy webhook (v1.4.15 — phase C2) # ----------------------------------------------------------------------------- # Coolify (or any CI) pings POST /api/internal/deploy-webhook after a deploy # attempt with success/failure. A request authenticates with this shared # secret in the `X-Deploy-Webhook-Secret` header (timing-safe compare; what # Coolify's stock sender does, with no replay protection), or by signing: # `X-Deploy-Webhook-Signature: sha256=`, the HMAC-SHA256 under this # secret of the `X-Deploy-Webhook-Timestamp` value, a dot and the raw body, # the timestamp within 5 minutes. Generate with `openssl rand -hex 32`. # Configure the SAME value in Coolify's Notifications → Webhook # custom-headers panel. See docs/ops/deploy.md. # DEPLOY_WEBHOOK_SECRET="" # "true" also requires a fresh X-Deploy-Webhook-Timestamp on a secret-header # request; leave unset for Coolify's stock sender, which sends none. # DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="" # "true" accepts signed requests only (the setting that stops a replay); # leave unset for Coolify's stock sender. # DEPLOY_WEBHOOK_REQUIRE_SIGNATURE="" # ----------------------------------------------------------------------------- # Outbound requests HealthLog makes on its own (optional switches) # ----------------------------------------------------------------------------- # Each accepts "1", "true" or "yes". See .env.production.example for detail. # PASSWORD_BREACH_CHECK_DISABLED="1" # no HaveIBeenPwned range query when a password is set # UPDATE_CHECK_DISABLED="1" # no daily GitHub releases request # IP_GEO_LOOKUP_DISABLED="1" # no online IP geolocation for login events # ----------------------------------------------------------------------------- # IP geolocation (optional) # ----------------------------------------------------------------------------- # Opt-in for a plain-HTTP IP_GEO_LOOKUP_URL provider (e.g. free ip-api.com); off by default — looked-up IPs then travel unencrypted over your server's egress. # IP_GEO_ALLOW_INSECURE="true" # # Directory the offline GeoLite2 databases are read from; default /opt/geolite2 (the path the image bakes its own copy into). Point it at a directory you mount yourself to bring your own MMDBs — see docs/self-hosting/geolite2.md. # GEOLITE2_DIR="/var/lib/geolite2" # # MaxMind licence key for the runtime GeoLite2 fetch (issue #659): set it and the worker downloads both databases into GEOLITE2_DIR itself, no rebuild or manual mount. Free key at https://www.maxmind.com/en/geolite2/signup — see docs/self-hosting/geolite2.md. # MAXMIND_LICENSE_KEY="your-maxmind-licence-key"