mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
A stored backup was one value in data_backups.data. v1.39.1 wrote it a piece at a time but still joined the pieces into that one value, and one value has to pass through the app whole, so a copy was capped at a fifth of the heap: 105 MB in the default 1 GB container. An account with 1.75 million readings is past that and could not be backed up at all (#1031). A copy is now kept as ordered pieces of about a megabyte in the new data_backup_chunks table (migration 0352). Each piece is sealed on its own with AES-256-GCM, and its sealed header names the copy (a random stream id kept on the row), its position and whether it is the last. A reader checks every piece before it releases a byte, so a piece that is missing, moved, altered, taken from another copy, or a copy cut short is refused with backup.payload.undecryptable before a restore deletes anything, and the second read during the restore checks each piece again. The write is one transaction, so the previous copy stays whole and readable until the new one commits. Pieces are written through the model API: a raw query with a large parameter is kept alive by Prisma's query plan cache, which left 160 MB of heap behind per hundred one-megabyte inserts. Restore, preview, download, the restore job and the admin list read both forms; copies written before this change keep their single value and still restore. The heap-derived limit is gone. The one limit left is on storage, BACKUP_MAX_STORED_MB (default 2048), and its message names the setting. Key rotation covers the pieces, the wipe and account deletion take them with their backup, and the backup plan lists the table as not carried. Measured on an account of 2.6 million readings in a 1 GB container with web and worker in one process: backup 87 s, 97 MiB in 96 pieces, preview 20 s, download 25 s, restore 264 s with every reading back exactly, peak 567 MiB, no restart.
306 lines
16 KiB
Bash
306 lines
16 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.
|
|
|
|
# Local-AI SSRF opt-in (optional). The local AI provider rejects private/
|
|
# internal hosts by default. Set to an exact host allowlist (comma-separated,
|
|
# e.g. "ollama.lan,10.0.0.5") or "true" for any private host — intended for a
|
|
# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md.
|
|
# 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"
|