mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
338 lines
18 KiB
Bash
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"
|