Files
HealthLog/.env.example
T
Marc-André Bombeck 7d05f68f23 fix(http): say when proxy headers go unread, and trust X-Real-IP only at one hop
With TRUST_PROXY_HOPS=0 behind a proxy that sends only X-Real-IP, every
client address resolves to nothing and all anonymous sign-ins share one
rate-limit bucket, with nothing in the log to say why. The first request
that carries a forwarding header the setting does not let HealthLog read
now logs one warning naming the header and the setting to change.

X-Real-IP stood in for a missing X-Forwarded-For at any hop count. It is a
single address, so it is now read only with TRUST_PROXY_HOPS=1; with two
or more hops the chain the count describes is X-Forwarded-For.

The upload, import and native batch routes that bypass the proxy now carry
Cross-Origin-Resource-Policy and Cross-Origin-Embedder-Policy like every
other response, and so do the proxy's early exits.

The env examples described an unset TRUST_PROXY_HOPS as right for a
directly exposed host; unset means one hop, and that host wants 0.
2026-09-26 22:45:51 +02:00

338 lines
18 KiB
Bash

# =============================================================================
# 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.
# 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"
# -----------------------------------------------------------------------------
# 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"
# 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. 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. 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"
# 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 <new-id>` to re-encrypt every
# existing row under the new key. See docs/ops/encryption-key-rotation.md.
# ENCRYPTION_KEYS='{"v1":"<old-key>","v2":"<new-key>"}'
# 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 <userId> <YYYY-MM-DD>`.
# 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/<ts>.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://<account>.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
# -----------------------------------------------------------------------------
# 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. We verify the request via this shared secret
# carried in the `X-Deploy-Webhook-Secret` header (timing-safe compare).
# Generate with `openssl rand -hex 32`. Configure the SAME value in Coolify's
# Notifications → Webhook custom-headers panel. See docs/audit/v1415-auto-deploy.md.
# DEPLOY_WEBHOOK_SECRET=""
# -----------------------------------------------------------------------------
# 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"