mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Making `=true` grant nothing would stop a single-user self-host's local model on a patch update. It is deprecated for one release cycle instead, in a narrowed and safe form: it still grants a private host, but only to the operator's own configurations (the instance-wide admin provider and AI settings saved on an admin account), and only through the pinned operator-approved dispatcher, so redirects stay forbidden and metadata, link-local and unspecified addresses stay refused. A non-admin account's own base URL needs an exact origin in AI_PRIVATE_ORIGINS. Provider resolution passes the owning account's role to the client (`operatorTrusted`); the save route and the connection test apply the same rule to the account being configured. The boot summary now says the setting is deprecated and names the replacement, and the admin AI settings page shows the exact AI_PRIVATE_ORIGINS line for the private origins saved on the instance (every account's Local and gateway URL and the admin base URL, scheme, host and port only), so the operator can copy it and drop the old setting.
311 lines
17 KiB
Bash
311 lines
17 KiB
Bash
# =============================================================================
|
|
# HealthLog — Environment Configuration
|
|
# =============================================================================
|
|
# Copy this file to .env and fill in the values.
|
|
# $ cp .env.example .env
|
|
#
|
|
# 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 paste the credentials
|
|
# here. Per-user OAuth credentials can also be configured in Settings.
|
|
# WITHINGS_CLIENT_ID=""
|
|
# WITHINGS_CLIENT_SECRET=""
|
|
# 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=""
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# 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. Leave
|
|
# unset for a directly-exposed host; set to 1 behind a single reverse proxy.
|
|
# A wrong value makes the app refuse to read XFF 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
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# 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=""
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# 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"
|