Files
HealthLog/.env.production.example

787 lines
42 KiB
Bash

# =============================================================================
# HealthLog -- production .env example
# =============================================================================
# Mirrors `scripts/env-manifest.json`. The `pnpm check-env --file
# .env.production.example` CI step (see `.github/workflows/env-check.yml`)
# enforces every variable declared `required: true` in the manifest has a
# non-empty placeholder here, so a contributor who introduces a new
# required var without updating this file fails the workflow.
#
# Placeholders are syntactic only -- replace with real secrets before
# deploying. The check-env presence rule treats any non-empty,
# non-whitespace string as "set", so the `<placeholder>` style below
# satisfies the manifest without leaking a usable credential.
#
# Generate the two crypto secrets locally with:
# $ openssl rand -hex 32
# =============================================================================
# -----------------------------------------------------------------------------
# Core -- required for boot
# -----------------------------------------------------------------------------
DATABASE_URL="postgresql://healthlog:<password>@db:5432/healthlog?schema=public"
POSTGRES_PASSWORD="<openssl rand -hex 32>"
NEXT_PUBLIC_APP_URL="https://healthlog.example.com"
APP_URL="https://healthlog.example.com"
ENCRYPTION_KEY="<openssl rand -hex 32>"
API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# -----------------------------------------------------------------------------
# Withings -- optional integration
# -----------------------------------------------------------------------------
# The Withings OAuth client id/secret are per-user: each user registers their
# own Withings developer app and pastes the pair into Settings (stored
# encrypted in the DB). There is therefore no WITHINGS_CLIENT_ID/SECRET.
# WITHINGS_WEBHOOK_SECRET=""
# Overrides the derived "<NEXT_PUBLIC_APP_URL>/api/withings/callback". Set it
# when the callback registered with Withings differs from the app's own
# public URL -- e.g. a second hostname in front of the same instance.
# WITHINGS_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Google Health Connect -- optional integration (per-user BYO-keys)
# -----------------------------------------------------------------------------
# Like Fitbit, the client id/secret are per-user and live encrypted in the DB.
# This only overrides the derived
# "<NEXT_PUBLIC_APP_URL>/api/google-health/callback"; it must stay same-origin
# with NEXT_PUBLIC_APP_URL or the handshake is refused.
# GOOGLE_HEALTH_REDIRECT_URI=""
#
# How many years back the history backfill reads after a connect: a whole
# number from 1 to 100, 20 when unset. A data type whose history goes further
# back ends at that point, and the integration card names the date.
# GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS="20"
# -----------------------------------------------------------------------------
# Telegram -- optional notification channel
# -----------------------------------------------------------------------------
# Shared secret for the Telegram bot webhook. It is both the path segment the
# bot posts to and the value compared against the update header, so set it
# before installing the webhook from Settings -> Notifications. Without it the
# settings page cannot register the webhook and inbound updates are refused.
# TELEGRAM_WEBHOOK_SECRET="<openssl rand -hex 32>"
# -----------------------------------------------------------------------------
# Nightscout -- optional integration
# -----------------------------------------------------------------------------
# Nightscout instances on your own network that the per-user connection form
# should accept, comma-separated (e.g. "http://nightscout.lan:1337"). The
# validator refuses private and internal hosts by default; this is the opt-in
# for a self-hosted Nightscout that is not reachable from the public internet.
# NIGHTSCOUT_PRIVATE_ORIGINS=""
# Dawarich instances on your own network that the movement-context connection
# may reach, comma-separated exact origins (e.g. "http://dawarich.lan:3000").
# Each entry is one scheme://host[:port], never a suffix or a range. A public
# Dawarich instance needs nothing here.
# DAWARICH_PRIVATE_ORIGINS=""
# -----------------------------------------------------------------------------
# Notification targets on a private network -- optional
# -----------------------------------------------------------------------------
# Webhook (Gotify, Home Assistant, n8n, a Discord relay) and ntfy targets on
# your own network, comma-separated exact origins (scheme://host[:port], no
# path). A target that resolves to a private address is otherwise refused at
# send time; the test button reports it as private_origin_not_approved. A
# listed origin is still dialled through the pinned resolver with redirects
# forbidden; link-local, metadata and unspecified addresses stay refused even
# when listed, and a malformed entry is logged once and grants nothing.
# 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=""
# -----------------------------------------------------------------------------
# WHOOP -- optional integration
# -----------------------------------------------------------------------------
# The WHOOP app client id/secret are per-user BYO-keys: each user registers
# their own WHOOP developer app and pastes the client id/secret into Settings
# (stored encrypted in the DB). There is therefore no WHOOP_CLIENT_ID/SECRET.
#
# WHOOP_WEBHOOK_SECRET is the instance-level secret carried as the trailing
# path segment of the webhook URL AND used to verify the WHOOP HMAC body
# signature. WHOOP_REDIRECT_URI overrides the derived
# `${NEXT_PUBLIC_APP_URL}/api/whoop/callback` when the public URL differs.
# WHOOP_WEBHOOK_SECRET="<openssl rand -hex 32>"
# WHOOP_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Fitbit -- optional integration (classic Web API, per-user BYO-keys)
# -----------------------------------------------------------------------------
# Each user registers their own app at https://dev.fitbit.com (self-serve, no
# brand verification) and pastes the OAuth Client ID + Client Secret into
# Settings (stored encrypted in the DB). There is therefore no
# FITBIT_CLIENT_ID/SECRET here. The OAuth flow uses Authorization Code + PKCE.
#
# FITBIT_REDIRECT_URI overrides the derived
# `${NEXT_PUBLIC_APP_URL}/api/fitbit/callback` when the public URL differs; it
# must exactly match the Callback URL registered in the dev.fitbit.com app.
# FITBIT_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Polar AccessLink -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
# Register an app at https://admin.polaraccesslink.com and set its redirect URI
# to `${NEXT_PUBLIC_APP_URL}/api/polar/callback`. Both vars together enable the
# Polar connect button; set neither to leave the integration disabled. Polar
# access tokens do not expire and carry no refresh token. POLAR_REDIRECT_URI
# overrides the derived callback when the public URL differs.
# POLAR_CLIENT_ID=""
# POLAR_CLIENT_SECRET=""
# POLAR_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Oura Cloud v2 -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
# Register an app at https://cloud.ouraring.com/oauth/applications and set its
# redirect URI to `${NEXT_PUBLIC_APP_URL}/api/oura/callback`. Enable the Daily,
# Personal, SpO2, Heart health and Stress permissions on it; HealthLog asks for
# exactly those. Both vars together enable the Oura connect button. Oura uses
# refresh tokens; the sync refreshes reactively on a 401. OURA_REDIRECT_URI
# overrides the derived callback.
# OURA_CLIENT_ID=""
# OURA_CLIENT_SECRET=""
# OURA_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Strava -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
# Register an app at https://www.strava.com/settings/api and set its
# "Authorization Callback Domain" to your instance host. Both vars together
# enable the Strava connect button; set neither to leave the integration
# disabled. Per-user BYO client id/secret in Settings take precedence over these
# shared env values. Strava caps every new app at single-player mode (athlete
# capacity 1) until you request a capacity increase — fine for a personal
# self-host. Strava rotates its refresh token on every refresh; the sync
# refreshes reactively on a 401. STRAVA_REDIRECT_URI overrides the derived
# `${NEXT_PUBLIC_APP_URL}/api/strava/callback`.
# STRAVA_CLIENT_ID=""
# STRAVA_CLIENT_SECRET=""
# STRAVA_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# OIDC SSO login -- optional (self-hosted identity provider)
# -----------------------------------------------------------------------------
# Register a confidential OIDC client for your IdP (Authentik, Keycloak,
# Authelia, Google Workspace, etc.) with redirect URI
# `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback`. All three of ISSUER_URL/
# CLIENT_ID/CLIENT_SECRET together enable the "Sign in with SSO" button on
# the login page; set none to leave it disabled. First-time sign-in
# auto-provisions (or links, by verified email) a HealthLog account. OIDC_ONLY
# hides password/passkey login entirely once set to "true" -- but only takes
# effect when the three vars above are also fully set, so a typo can't lock
# every user out. Enforced server-side, not just a hidden button. The native
# iOS client signs in through the native OIDC SSO handoff (the app opens the
# login URL in an in-app browser and completes at a custom-scheme callback), so
# OIDC_ONLY="true" is supported for iOS -- but only on the app build that ships
# that flow. An older password/passkey-only iOS build is locked out of sign-in
# by OIDC_ONLY; confirm the app version before enabling it on a deployment with
# active iOS users.
# Serve the issuer over HTTPS wherever possible: sign-in material (the
# authorization code, the token-endpoint response carrying the ID token)
# crosses this connection. Plain HTTP is defensible only on a fully trusted
# private network (IdP and app on the same box, a Tailscale mesh, a VPN).
# OIDC_ISSUER_URL=""
# OIDC_CLIENT_ID=""
# OIDC_CLIENT_SECRET=""
# OIDC_SCOPES="openid email profile"
# OIDC_BUTTON_LABEL="Single Sign-On"
# OIDC_ONLY="false"
# -----------------------------------------------------------------------------
# Web Push -- VAPID keys (optional; the admin panel is the easier path)
# -----------------------------------------------------------------------------
# Web Push needs NO Apple account and no paid service -- just a VAPID keypair.
# The EASIEST path is the admin panel: /admin -> Web Push VAPID -> "Generate
# keys" mints and stores the pair for you (private key encrypted at rest). Use
# these env vars only if you prefer env-config over the DB. The loader reads
# DB first, then env (src/lib/notifications/vapid-config.ts). These three
# names are the only ones it reads, and all three are on docker-compose.yml's
# `environment:` whitelist, so a value set here reaches the container.
# VAPID_PUBLIC_KEY=""
# VAPID_PRIVATE_KEY=""
# VAPID_SUBJECT="mailto:you@example.com"
# -----------------------------------------------------------------------------
# APNs -- iOS push (optional, all-or-none)
# -----------------------------------------------------------------------------
# Only the native iOS app uses APNs, and it needs a paid Apple Developer
# Program membership. Web Push (above) needs NO Apple account -- it covers
# every browser and the installed PWA on iPhone. Leave this whole block unset
# unless you ship the native app. Key-source precedence when more than one is
# set: APNS_KEY_B64 > APNS_KEY > APNS_KEY_FILE.
# APNS_KEY_ID=""
# APNS_TEAM_ID=""
# APNS_BUNDLE_ID=""
# APNS_KEY_B64="" # PEM body base64-encoded -- recommended (escapes newlines)
# APNS_KEY="" # OR APNS_KEY (raw PEM) OR APNS_KEY_FILE -- one satisfies the manifest
# APNS_KEY_FILE=""
# APNS_CRITICAL_ENTITLEMENT="" # "true" only if your build has Apple's Critical Alerts entitlement; else urgent = time-sensitive
# APNS_PRODUCTION="" # "true" forces the production APNs gateway; normally derived from NODE_ENV
# -----------------------------------------------------------------------------
# SMTP -- email notification channel (optional, all-or-none)
# -----------------------------------------------------------------------------
# Operator SMTP transport for the email channel. SMTP_HOST + SMTP_PORT +
# SMTP_FROM together enable it; SMTP_USER/SMTP_PASS are optional (omit for an
# unauthenticated relay). SMTP_SECURE=true uses implicit TLS (port 465);
# unset/false uses STARTTLS (port 587). Per-user recipient address is set in
# Settings -> Notifications, not here. Missing the core trio disables the
# channel silently. Listed in docker-compose.yml's environment whitelist so
# these reach the container.
# SMTP_HOST=""
# SMTP_PORT="587"
# SMTP_FROM="HealthLog <noreply@example.com>"
# SMTP_USER=""
# SMTP_PASS=""
# SMTP_SECURE="false"
# -----------------------------------------------------------------------------
# Session cookie Secure flag -- the most common self-host snag
# -----------------------------------------------------------------------------
# Unset, the flag tracks NODE_ENV, so a production image sets `Secure` and a
# browser on plain HTTP silently drops the session cookie: login appears to
# succeed and every following request is anonymous. Set it to false ONLY on a
# LAN, Tailscale or VPN-only host that deliberately serves HTTP. Behind a TLS
# reverse proxy, leave it alone.
# SESSION_COOKIE_SECURE="false"
# -----------------------------------------------------------------------------
# Platform hint for the encryption key backup step
# -----------------------------------------------------------------------------
# Where this server's environment lives: compose, truenas, coolify, unraid or
# portainer. The admin's "Back up your encryption key" step opens on the
# instructions for that platform. Unset means compose. Catalog templates set
# it for you.
# 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, reports it as a
# warning on /api/health, 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. Remove it once the key is confirmed.
# ENCRYPTION_KEY_CHECK="enforce"
# -----------------------------------------------------------------------------
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
# -----------------------------------------------------------------------------
# DEPLOY_WEBHOOK_SECRET=""
# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. When unset the notification omits the link.
# DEPLOY_LOGS_URL=""
# Optional: require an X-Deploy-Webhook-Timestamp within +/- 5 minutes on a
# request that authenticates with the secret header. Keeps a stale capture
# out, not a fresh forgery by someone who saw the secret. Leave unset for
# Coolify's stock sender, which sends no timestamp.
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
# Optional replay protection: accept only signed webhook calls
# (X-Deploy-Webhook-Signature, an HMAC of the timestamp and body under the
# secret, with a timestamp within +/- 5 minutes); the secret header alone is
# refused. Leave unset for Coolify's stock sender, which can only send the
# secret header; see docs/ops/deploy.md.
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE="false"
# -----------------------------------------------------------------------------
# TLS leaf pin monitor -- alarm when the pinned leaf certificate rotates
# -----------------------------------------------------------------------------
# The native client SPKI-pins the served TLS leaf. Set this to the
# known-good leaf pin(s) so the monitor can alarm on a rotation. The value
# is base64(sha256(DER subjectPublicKeyInfo)); comma-separate to hold both
# the current and the next pin during a dual-pin renewal window. See
# docs/ops/tls-cert-pin.md for extraction + the re-pin runbook.
# TLS_LEAF_SPKI_PINS=""
# 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=""
# Per-user hourly ceiling for POST /api/insights/generate (optional).
# Default 10. Lower it on a tight LLM budget; the 24h cache already
# short-circuits read traffic. See docs/integrations/ai-providers.md.
# INSIGHTS_RATE_LIMIT_PER_HOUR=""
# Per-user hourly ceiling for the document-AI actions (optional). Read/index,
# extract, suggest and summarise share one bucket per user. Default 6,
# clamped to 1-1000. Raise it when users batch-read many documents against a
# generous provider budget; lower it on a tight one. See
# docs/integrations/ai-providers.md.
# DOCUMENT_AI_LIMIT_PER_HOUR=""
# Minimum hours between two regenerations of a status card after a sync
# (optional). A watch or an integration delivers readings all day, and each
# delivery would otherwise regenerate the cards it touches every hour. Hand
# entries keep a one-hour gap. Default 6, whole hours from 1 to 48. The value
# under Admin > Coach wins over this one. See docs/integrations/ai-providers.md.
# AI_SYNC_REGEN_GAP_HOURS=""
# Hourly upload ceiling for each document-upload token (optional). Applies
# only to uploads made with a token minted under Settings > API & Tokens >
# Document tokens: a Paperless workflow or the import script. Every upload
# that sends a file counts, duplicates included. Default 120, clamped to
# 1-1000. Uploads from the web app and the phone keep their own bucket. Raise
# it to import a large archive faster; the per-user storage quota still
# applies. See docs/integrations/document-import.md.
# DOCUMENT_UPLOAD_LIMIT_PER_HOUR=""
# Per-user hourly ceiling for lab-report scans (optional). The photo/PDF scan
# and the browser-OCR text scan share one bucket per user. Default 6, clamped
# to 1-1000. Raise it when users scan a stack of reports at once against a
# generous provider budget; cost stays bounded by the daily AI budget. See
# docs/integrations/ai-providers.md.
# LABS_OCR_LIMIT_PER_HOUR=""
# Extra hosts the ADMIN-configured AI base URL may point at, on top of the
# built-in public providers (comma-separated bare hostnames). Set it when you
# run a gateway this instance should trust. It widens the admin setting only;
# per-user BYO keys keep the pinned public-host posture either way.
# ADMIN_AI_BASE_URL_ALLOWLIST=""
# Codex (ChatGPT sign-in) model slugs. Left unset, each sign-in picks from
# the models its account is offered (read from the provider the way the Codex
# CLI reads it), with a built-in order as the fallback. CODEX_MODEL pins one
# slug ahead of that; CODEX_MODEL_FALLBACK_CHAIN replaces the order outright
# (comma-separated, first match wins) and the account's list is then not read.
# CODEX_MODEL=""
# CODEX_MODEL_FALLBACK_CHAIN=""
# Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot. Off by
# default: the read-back is the most overclaim-prone Coach surface, so it is a
# deliberate operator opt-in. Set to "1" to enable; anything else keeps it
# dormant while the review worker still records outcomes.
# COACH_EXPERIMENT_VERDICT="1"
# -----------------------------------------------------------------------------
# Off-host backups -- all-or-none
# -----------------------------------------------------------------------------
# BACKUP_S3_ENDPOINT=""
# BACKUP_S3_BUCKET=""
# BACKUP_S3_ACCESS_KEY=""
# BACKUP_S3_SECRET_KEY=""
# BACKUP_ENCRYPTION_KEY=""
# The two optional members of the set, both with defaults, so leaving them
# unset does not disable backups.
#
# Region sent with the SigV4 signature. Default "auto", which is what
# Cloudflare R2 expects; AWS S3 and most MinIO deployments want their real
# region name here.
# BACKUP_S3_REGION="auto"
#
# How long backup objects are meant to live, in days. Default 30, minimum 1.
# The app does not expire objects by age: this number is what you set the
# bucket's own lifecycle rule to. It does delete objects in two cases, so the
# credential needs DeleteObject: every copy of an account that is deleted or
# whose data is wiped, and its own connection test's `_healthcheck/<ts>.bin`.
# See docs/ops/backup-restore.md.
# BACKUP_RETENTION_DAYS="30"
#
# Off-host keys no longer written with, comma-separated, kept so older
# objects stay readable during a rotation of BACKUP_ENCRYPTION_KEY. Remove a
# key once the lifecycle rule has retired every object written under it.
# BACKUP_ENCRYPTION_PREVIOUS_KEYS=""
# -----------------------------------------------------------------------------
# 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` survive, in days. Default 365. The floor is 7:
# a smaller value is ignored rather than obeyed, so a window entered in seconds
# by mistake cannot empty a fresh table. A daily job does the pruning.
#
# This number is shown to users, not just enforced. Settings -> Shared access
# prints the window under the activity list, and the dialog that ends somebody
# else's access states how long "who entered what" stays answerable. Both take
# the value the server resolved from this variable, so an instance running 90
# says 90 on both surfaces.
# AUDIT_LOG_RETENTION_DAYS="365"
# How long Coach conversations survive, in days. Default 365, floor 30. Like
# the audit window above this number is shown to the user -- the Data &
# Privacy page states it -- so an instance running 90 has to be able to say
# 90 rather than repeat the default.
# COACH_MESSAGE_RETENTION_DAYS="365"
# How long deleting an account, a managed profile or "all data" stays
# undoable, in days. Default 14, ceiling 30. The account is locked at once
# (signed out everywhere, sign-in refused, nothing runs for it) and an admin
# can undo the deletion from the users list until the window ends; the purge
# job erases it then, together with its stored and off-host copies. A wipe
# keeps a copy of the record an admin can restore for the same time. 0 erases
# immediately, as releases before 1.46 did. The Data & Privacy page states
# this number, so it has to reach the container.
# ACCOUNT_DELETION_GRACE_DAYS="14"
# How long the host CPU/memory samples behind the admin monitoring charts are
# kept, in days. Default 7. Raise it for a longer trend; lower it on a host
# where disk is the scarce resource.
# HOST_METRIC_RETENTION_DAYS="7"
# Thinning of dense intraday samples (heart rate, glucose) once they age out
# of the detail window. On by default. Set "false" to keep every raw sample
# forever -- accurate to the second, at the cost of unbounded table growth.
# DENSE_INTRADAY_RETENTION_ENABLED="false"
# Consecutive failures before an integration is reported as persistently
# failing and notified about. Default 3, minimum 1. Raise it when a flaky
# upstream makes the notification noisy.
# INTEGRATION_FAILURE_ALERT_THRESHOLD="3"
# -----------------------------------------------------------------------------
# Database session timeout -- optional
# -----------------------------------------------------------------------------
# Statement timeout applied to every database session, in milliseconds.
# Default 60000, which bounds a genuinely runaway query while leaving headroom
# for the heaviest legitimate read (the live-aggregate analytics fallback on a
# large account). Set "0" to disable it and restore unbounded behaviour.
# DATABASE_STATEMENT_TIMEOUT_MS="60000"
#
# Postgres `work_mem` applied to every app session, as a Postgres size string.
# Default 16MB. Large sorts and aggregates that exceed it spill to disk; each
# sort step of each pooled connection may use this much memory, so raise it
# with the pool size in mind.
# DATABASE_WORK_MEM="16MB"
#
# Both settings above reach Postgres as the libpq `options` startup
# parameter. PgBouncer refuses that parameter ("unsupported startup
# parameter: options") unless it is listed in its ignore_startup_parameters.
# Behind such a pooler, set this to 1, true or yes to send no session
# settings at all; the pooler's or the database's own defaults then apply.
# DATABASE_SESSION_OPTIONS_DISABLED="1"
#
# The app runs as `healthlog_app_<database>_<hash>`, a database role the
# container creates for this database on every start: it reads and writes
# rows but cannot alter or drop tables, own anything, or create roles. Migrations and restores still use the user in DATABASE_URL.
# Where that user may not create roles (a managed database), the start log
# says so and the app uses DATABASE_URL as given. Set "off" if you manage
# the database roles yourself. See docs/self-hosting/database-role.md.
# DB_APP_ROLE="off"
# -----------------------------------------------------------------------------
# FHIR health-record export -- optional
# -----------------------------------------------------------------------------
# Per-export ceiling on the number of MedicationAdministration rows in the
# FHIR Bundle. The report window already scopes the set; this is a coarse
# safety ceiling on the serialised Bundle size. Bounded 1..50000; any unset
# or out-of-range value falls back to the default of 5000. Raise it only for
# an unusual multi-year, many-medication workload.
# FHIR_MAX_MEDICATION_ADMINISTRATIONS="5000"
# -----------------------------------------------------------------------------
# Dashboard snapshot -- BUILD-TIME toggle (default ON)
# -----------------------------------------------------------------------------
# The dashboard hydrates every above-the-fold tile from the single
# `GET /api/dashboard/snapshot` cell so the whole strip shares one
# completion moment. This is the default and the desired behaviour.
#
# `NEXT_PUBLIC_*` vars are inlined into the client bundle at BUILD time,
# not read at runtime -- so this has NO effect on the prebuilt GHCR
# image and is intentionally absent from the `docker-compose.yml`
# runtime `environment:` whitelist. To fall back to the legacy
# four-cell path you must rebuild the image with the var set to "false":
#
# $ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build # or a Dockerfile ARG
#
# NEXT_PUBLIC_DASHBOARD_SNAPSHOT="false"
# -----------------------------------------------------------------------------
# Client-IP resolution -- optional (required behind Cloudflare)
# -----------------------------------------------------------------------------
# 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. 0 for a
# directly-exposed host (the connection's own address is used), 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 collapse
# anonymous callers into one rate-limit bucket.
# TRUST_PROXY_HOPS="1"
#
# The addresses your reverse proxy connects from, as comma-separated CIDRs
# (IPv4 or IPv6; a bare address is one host). When set, only those
# connections may report the client's address; every other connection is
# its own client. When unset, any private address may, which is the old
# behaviour and is NOT safe behind Docker Desktop, OrbStack or docker-proxy:
# there every caller of the published port arrives from the gateway, a
# private address, and could name its own client address. Set it whenever a
# reverse proxy is in front:
# proxy on the same host (proxying to 127.0.0.1:3000): 127.0.0.1/32,::1/128
# proxy container on a Docker network: that network's subnet (docker
# network inspect <network>), e.g. 172.18.0.0/16
# proxy on another machine with a public address: that address, /32
# A proxy on a public address that is not listed is not believed: every
# request through it shares the proxy's address and one rate-limit bucket.
# The boot log names the rule in force in one [proxy-trust] line.
# TRUSTED_PROXY_CIDRS="127.0.0.1/32,::1/128"
#
# Behind Cloudflare the real visitor IP arrives in the `cf-connecting-ip`
# header; the X-Forwarded-For chain otherwise yields the Cloudflare edge IP,
# so the audit-log overview stores the CF datacentre address and the geo
# lookup resolves the wrong location. "1" believes that header from EVERY
# connection, before the checks above, so set it ONLY when the origin accepts
# connections from Cloudflare alone (a Cloudflare Tunnel, or a firewall that
# admits only Cloudflare's address ranges). An origin that also answers on
# its own public address lets any caller forge the address that rate limits
# and the audit log record. Off by default.
# TRUST_CF_CONNECTING_IP="1"
# Memory cap of the app container (read by docker-compose.yml, not by the app).
# Node sizes its heap from it: 1536m (the default) gives 792 MB. 1536m is the
# supported floor: below it a long-used account fills the heap in normal
# operation, and the boot log, the support report and Admin > System status
# say the app runs below the supported floor. The host needs 3 GB of RAM,
# 4 GB recommended. Raise it on a busy instance; do not lower it.
# APP_MEMORY_LIMIT="1536m"
# -----------------------------------------------------------------------------
# Outbound requests HealthLog makes on its own -- optional switches
# -----------------------------------------------------------------------------
# Besides the services you connect yourself (AI provider, integrations,
# notification channels, off-host backups), the server contacts three third
# parties by default. Each has an off-switch that accepts "1", "true" or "yes":
#
# - HaveIBeenPwned, when a password is set (registration, change, admin
# reset). Only the first five hex characters of the password's SHA-1 are
# sent (k-anonymity); the password and its full hash never leave. With the
# switch on, known-breached passwords are no longer refused.
# PASSWORD_BREACH_CHECK_DISABLED="1"
#
# - The GitHub releases API, at most once a day per browser, for the "update
# available" badge on the About page and the admin version tile.
# UPDATE_CHECK_DISABLED="1"
#
# - The online IP geolocation provider for login events; see
# IP_GEO_LOOKUP_DISABLED in the next section.
# -----------------------------------------------------------------------------
# IP geolocation lookup -- optional
# -----------------------------------------------------------------------------
# The admin login/audit overview enriches auth events with a coarse
# "City, CC" location + carrier. The default online provider is ipwho.is
# (free, no key, HTTPS). Override the base URL to point at a different
# provider; the response must match either the ipwho.is shape (`success` +
# `country_code`) or the ip-api.com shape (`status` + `countryCode`). The
# `/<ip>` segment is appended automatically.
# IP_GEO_LOOKUP_URL="https://ipwho.is"
#
# By default a non-HTTPS URL is refused (audit-event IPs must never egress over
# plaintext HTTP). The free ip-api.com endpoint is HTTP-only (its HTTPS form
# needs a paid key) but is often more accurate. To use it, set BOTH the HTTP
# URL and the explicit opt-in below -- the looked-up IP then travels in clear
# over your server's own egress; this is your call to make.
# IP_GEO_LOOKUP_URL="http://ip-api.com/json"
# IP_GEO_ALLOW_INSECURE="true"
#
# Set to "1" (or "true" / "yes") to disable all third-party IP egress (GDPR
# Art. 44). With egress off the resolver uses only the offline GeoLite2 MMDB
# tier, if present, and the monthly MaxMind download is skipped too.
# IP_GEO_LOOKUP_DISABLED="1"
# -----------------------------------------------------------------------------
# Offline GeoLite2 databases -- optional
# -----------------------------------------------------------------------------
# Where the runtime looks for GeoLite2-City.mmdb and GeoLite2-ASN.mmdb.
# Unset it reads /opt/geolite2, the path the published image bakes its own
# copy into -- but only if the image build had a MaxMind licence key, so on
# a stock `:latest` the directory is usually empty and every login IP goes
# to the online provider instead.
#
# Three ways to get the databases:
#
# 1. RUNTIME FETCH (issue #659) -- set MAXMIND_LICENSE_KEY below and the
# worker downloads both databases into GEOLITE2_DIR itself on the next
# boot and monthly thereafter. No image rebuild, no manual mount. This is
# the way for a stock `:latest` pull.
# 2. Bring your own -- create a free MaxMind account, pull both databases
# with `geoipupdate`, mount the directory into the container read-only
# (the commented `volumes:` block in docker-compose.yml shows the line),
# and point GEOLITE2_DIR at the container path. Refreshing the files on
# the host is enough -- the reader picks them up on the next restart.
# 3. Bake them at build time (build your own image with a licence key).
#
# Pair any of them with IP_GEO_LOOKUP_DISABLED="1" above for zero IP egress.
# See docs/self-hosting/geolite2.md.
# GEOLITE2_DIR="/var/lib/geolite2"
#
# MaxMind licence key for the runtime fetch (option 1 above). Free: sign up at
# https://www.maxmind.com/en/geolite2/signup, then My Account -> Manage
# License Keys. Unset, nothing is fetched and the online provider answers.
# MAXMIND_LICENSE_KEY="your-maxmind-licence-key"
# -----------------------------------------------------------------------------
# Environmental-context module (Open-Meteo) -- optional
# -----------------------------------------------------------------------------
# The opt-in Environment module fetches daily weather / daylight for the user's
# coarse home (or a travel override) from Open-Meteo. The hosted endpoints are
# keyless and free for non-commercial use; data is CC BY 4.0. Both URLs are
# optional -- unset uses the hosted defaults below. A privacy-maximalist or
# commercial self-hoster can run their own Open-Meteo instance and point these
# at it (a private/LAN host is allowed -- the fetch is server-side and does NOT
# force the public-host guard, so an internal instance is not blocked).
# OPENMETEO_BASE_URL="https://archive-api.open-meteo.com"
# OPENMETEO_GEOCODING_URL="https://geocoding-api.open-meteo.com"
#
# Air quality, pollen and UV come from the Open-Meteo air-quality API, which
# serves the Copernicus Atmosphere Monitoring Service (CAMS) models. CAMS data
# is free to use with attribution, which the app shows next to the values.
# Like the URLs above this one is optional and can point at a self-hosted
# Open-Meteo instance. Set ENVIRONMENT_AIR_QUALITY_DISABLED to 1, true or yes
# to stop the air-quality requests for the whole instance; weather and
# daylight keep working, and no user can turn air quality back on.
# OPENMETEO_AIR_QUALITY_URL="https://air-quality-api.open-meteo.com"
# ENVIRONMENT_AIR_QUALITY_DISABLED="1"
# ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ──
# On x86-64 the renderer is enabled only on CPUs with AVX2. On older chips
# (Celeron/Atom-class NAS CPUs) it is disabled automatically and those features
# degrade cleanly: no thumbnails, and an AI provider that cannot take PDF files
# reads a PDF by its text layer, so a scanned PDF needs a provider that takes
# PDFs directly (Anthropic) or photos of its pages. On arm64 the published image
# renders on every 64-bit core, the Raspberry Pi 4 included. Only a musl
# (Alpine) build of your own needs ARMv8.2 (LSE atomics, RCpc) and is gated the
# same way. Override only if auto-detection misfires:
# off — force-disable the renderer
# on — force-enable (crashes PDF processing if the CPU truly lacks them)
# NATIVE_CANVAS="off"
# -----------------------------------------------------------------------------
# Logging -- optional
# -----------------------------------------------------------------------------
# LOG_LEVEL is one of debug / info / warn / error. Default info: drop to debug
# while chasing a problem, raise to warn on a busy instance. LOG_SAMPLE_RATE
# keeps that fraction of SUCCESSFUL events (default 1.0 = all; 0.1 thins a
# chatty log without losing errors). LOG_SLOW_THRESHOLD_MS is the duration
# above which a request is flagged slow (default 3000). LOG_INCLUDE_STACK set
# to "false" strips stack traces from the emitted events.
# LOG_LEVEL="info"
# LOG_SAMPLE_RATE="1.0"
# LOG_SLOW_THRESHOLD_MS="3000"
# LOG_INCLUDE_STACK="false"
# Heap diagnostics, off by default. The process always logs its heap
# (runtime.heap.sampled) and, at 85 % of the limit, one report of the work in
# flight (runtime.heap.pressure). HEALTHLOG_HEAP_SNAPSHOT=1 additionally writes
# ONE heap snapshot at that moment 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 log shipping to a Loki instance. LOKI_ENDPOINT turns shipping on.
# Either form works: the base URL (http://loki:3100) or the full push URL
# (http://loki:3100/loki/api/v1/push). A Loki on your own network is fine,
# private addresses and internal hostnames included; only link-local,
# cloud-metadata and the unspecified address (0.0.0.0, ::) are refused. The
# username/password pair is only needed when your Loki sits behind basic
# auth. When a push fails (Loki down, wrong path, rejected credentials) the
# app writes one line to stderr naming the Loki origin, the reason and how
# many events were dropped, at most once every five minutes per reason; the
# password never appears in it. Leave all three unset and logs stay on stdout
# for the Docker log driver to rotate.
# LOKI_ENDPOINT=""
# LOKI_USERNAME=""
# LOKI_PASSWORD=""
# -----------------------------------------------------------------------------
# Demo mode -- read-only showcase instance
# -----------------------------------------------------------------------------
# "true" makes the proxy refuse every mutation except login and paints a demo
# banner. This is the switch behind a public try-it-out deployment, not a
# safety belt for a real one -- leave it unset on an instance holding real
# data.
# DEMO_MODE="true"
# -----------------------------------------------------------------------------
# Server-side prefetch -- diagnostics escape hatch
# -----------------------------------------------------------------------------
# The dashboard, insights, coach and checkups pages prefetch their data on the
# server so the first paint arrives filled in. Set "false" to fall back to
# pure client fetching. Reach for it only when a prefetch is misbehaving on
# your host and you want to see whether the client path is healthy.
# DASHBOARD_SSR_PREFETCH="false"