{ "$schema": "./env-manifest.schema.json", "description": "Manifest of env vars required by a HealthLog production deployment. Used by `pnpm check-env` to catch missing/empty vars BEFORE a deploy ships. v1.4.42 W6 \u2014 born out of the v1.4.40 AP-2 .p8 install gap that shipped silently for 3 days.", "groups": [ { "name": "Core", "description": "Database, app URL, encryption keys. The app refuses to boot without these.", "required": true, "variables": [ { "name": "DATABASE_URL", "purpose": "Postgres connection string. App boot fails if missing.", "example": "postgresql://healthlog:@db:5432/healthlog?schema=public" }, { "name": "POSTGRES_PASSWORD", "purpose": "Database password for the bundled `db` service in docker-compose.yml.", "example": "" }, { "name": "NEXT_PUBLIC_APP_URL", "purpose": "Public URL embedded in OAuth redirects, webhook callbacks, APNs deep-links." }, { "name": "APP_URL", "purpose": "Server-side mirror of NEXT_PUBLIC_APP_URL; used by background workers." }, { "name": "ENCRYPTION_KEY", "purpose": "AES-256-GCM key for at-rest encryption of provider tokens. 64 hex chars (32 bytes). Required unless ENCRYPTION_KEYS is set." }, { "name": "API_TOKEN_HMAC_KEY", "purpose": "HMAC-SHA256 key for hashing external Bearer tokens AND web session cookies. 64 hex chars. Rotating it signs every browser session out on top of invalidating every issued token." } ] }, { "name": "Withings OAuth", "description": "Withings API credentials are entered per user in Settings \u2192 Integrations (no env-var path). Only the webhook shared secret is operator-level.", "required": false, "variables": [ { "name": "WITHINGS_WEBHOOK_SECRET", "purpose": "Random secret carried as a path segment in the Withings webhook callback URL. Generate with `openssl rand -hex 32`." } ] }, { "name": "Polar OAuth", "description": "Required to enable the Polar AccessLink integration. App boots without these but the Polar connect button stays disabled. Register an app at https://admin.polaraccesslink.com and set the redirect URI to ${NEXT_PUBLIC_APP_URL}/api/polar/callback.", "required": false, "allOrNone": true, "variables": [ { "name": "POLAR_CLIENT_ID", "purpose": "Polar AccessLink OAuth client id." }, { "name": "POLAR_CLIENT_SECRET", "purpose": "Polar AccessLink OAuth client secret." } ] }, { "name": "Oura OAuth", "description": "Required to enable the Oura Cloud v2 integration. App boots without these but the Oura connect button stays disabled. Register an app at https://cloud.ouraring.com/oauth/applications and set the redirect URI to ${NEXT_PUBLIC_APP_URL}/api/oura/callback.", "required": false, "allOrNone": true, "variables": [ { "name": "OURA_CLIENT_ID", "purpose": "Oura Cloud OAuth client id." }, { "name": "OURA_CLIENT_SECRET", "purpose": "Oura Cloud OAuth client secret." } ] }, { "name": "OIDC SSO login", "description": "Required to enable SSO login against a self-hosted identity provider (Authentik, Keycloak, Authelia, Google Workspace, etc.). App boots without these but the login page's SSO button stays hidden. Register a confidential client with redirect URI ${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback.", "required": false, "allOrNone": true, "variables": [ { "name": "OIDC_ISSUER_URL", "purpose": "OIDC provider issuer URL (discovery doc served at /.well-known/openid-configuration)." }, { "name": "OIDC_CLIENT_ID", "purpose": "OIDC confidential client id." }, { "name": "OIDC_CLIENT_SECRET", "purpose": "OIDC confidential client secret." } ] }, { "name": "OIDC SSO options", "description": "Optional tuning for an enabled OIDC provider. Each stands alone (deliberately NOT allOrNone \u2014 any subset is valid) and each has a safe default when unset.", "required": false, "variables": [ { "name": "OIDC_SCOPES", "purpose": "Space-separated scopes requested at authorization. Default: \"openid email profile\"." }, { "name": "OIDC_BUTTON_LABEL", "purpose": "Label on the login page's SSO button. Default: \"Single Sign-On\"." }, { "name": "OIDC_ONLY", "purpose": "\"true\" disables password/passkey login server-side (only effective when the OIDC provider group is fully configured). Default: additive login. Locks the native iOS app out of sign-in." } ] }, { "name": "AI Provider", "description": "Optional runtime policy and output tuning for user-configured AI providers. Each variable stands alone and has a safe default when unset.", "required": false, "variables": [ { "name": "AI_PRIVATE_ORIGINS", "purpose": "AI endpoints on the operator's own network (Ollama, LM Studio, LiteLLM). Comma-separated exact origins (scheme://host[:port]), e.g. \"http://ollama.lan:11434\". Unset, a private AI base URL is refused (apart from what the deprecated ALLOW_LOCAL_AI_PRIVATE_HOSTS=true still covers). Link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry is logged once and grants nothing." }, { "name": "ALLOW_LOCAL_AI_PRIVATE_HOSTS", "purpose": "Older form of AI_PRIVATE_ORIGINS, kept for existing installs: an exact comma-separated host list still grants those hosts. \"true\" is deprecated since v1.39.3: it covers only the admin AI key and AI settings saved on an admin account (pinned; metadata and link-local refused), is reported at boot and on the admin AI settings page, and will be removed in a later release." }, { "name": "INSIGHTS_MAX_TOKENS", "purpose": "Daily-briefing output-token ceiling. Default 2500, clamped to 500-8000." }, { "name": "INSIGHTS_RATE_LIMIT_PER_HOUR", "purpose": "Per-user hourly ceiling for POST /api/insights/generate. Default 10; values below 1 fall back to the default." }, { "name": "DOCUMENT_AI_LIMIT_PER_HOUR", "purpose": "Per-user hourly ceiling for the document-AI actions (read/index, extract, suggest, summarise share one bucket). Default 6, clamped to 1-1000." }, { "name": "AI_SYNC_REGEN_GAP_HOURS", "purpose": "Minimum hours between two status-card regenerations after a sync. Default 6, whole hours 1-48; hand entries keep one hour. The admin AI setting wins." }, { "name": "DOCUMENT_UPLOAD_LIMIT_PER_HOUR", "purpose": "Hourly upload ceiling per document-upload token (Paperless workflow, import script). Default 120, clamped to 1-1000. Web and phone uploads keep their own bucket." }, { "name": "LABS_OCR_LIMIT_PER_HOUR", "purpose": "Per-user hourly ceiling for lab-report scans (photo/PDF and browser-OCR text share one bucket). Default 6, clamped to 1-1000." } ] }, { "name": "Offline geolocation", "description": "Optional offline GeoLite2 tier for the admin login overview. Unset, the runtime reads /opt/geolite2 (the path the image bakes its own copy into) and falls back to the online provider when the databases are absent.", "required": false, "variables": [ { "name": "GEOLITE2_DIR", "purpose": "Directory holding GeoLite2-City.mmdb + GeoLite2-ASN.mmdb. Point it at a read-only mount to bring your own databases. Default /opt/geolite2." }, { "name": "MAXMIND_LICENSE_KEY", "purpose": "MaxMind licence key for the runtime GeoLite2 fetch (issue #659). Set it and the worker downloads both databases into GEOLITE2_DIR on boot and monthly, no rebuild or manual mount. Free at maxmind.com/en/geolite2/signup." } ] }, { "name": "Outbound requests", "description": "Switches for the requests HealthLog makes to third parties on its own. All default to on; set any of them to 1, true or yes to turn that request off.", "required": false, "variables": [ { "name": "IP_GEO_LOOKUP_DISABLED", "purpose": "Stops the online IP-to-location lookup for login events and the monthly MaxMind download. Only the local GeoLite2 databases are used." }, { "name": "PASSWORD_BREACH_CHECK_DISABLED", "purpose": "Stops the HaveIBeenPwned range query made when a password is set. The query carries the first five hex characters of the password's SHA-1, never the password or the full hash." }, { "name": "UPDATE_CHECK_DISABLED", "purpose": "Stops the daily request to the GitHub releases API behind the About page and the admin version tile." } ] }, { "name": "Environmental context (Open-Meteo)", "description": "Endpoints and switches for the opt-in Environment module. Every variable is optional: unset uses the hosted, keyless Open-Meteo services. Point the URLs at a self-hosted Open-Meteo instance to keep coordinate lookups off a third party.", "required": false, "variables": [ { "name": "OPENMETEO_BASE_URL", "purpose": "Historical weather (archive) API base URL. Default https://archive-api.open-meteo.com." }, { "name": "OPENMETEO_GEOCODING_URL", "purpose": "Geocoding API base URL for the home and travel location search. Default https://geocoding-api.open-meteo.com." }, { "name": "OPENMETEO_AIR_QUALITY_URL", "purpose": "Air-quality API base URL (CAMS air quality, pollen, UV). Default https://air-quality-api.open-meteo.com. CAMS data is licensed by the Copernicus Atmosphere Monitoring Service; the attribution is shown in the app." }, { "name": "ENVIRONMENT_AIR_QUALITY_DISABLED", "purpose": "Set to 1, true or yes to stop the air-quality requests for the whole instance. Weather and daylight keep working; the per-user switch cannot turn air quality back on." } ] }, { "name": "Database session tuning", "description": "Per-session Postgres settings the app applies to every pooled connection.", "required": false, "variables": [ { "name": "DATABASE_WORK_MEM", "purpose": "Postgres work_mem for every app session, as a Postgres size string. Default 16MB. Raise it when large sorts and aggregates spill to disk; each sort step of each connection may use this much." }, { "name": "DATABASE_SESSION_OPTIONS_DISABLED", "purpose": "Set to 1, true or yes to send no session settings (statement timeout, work_mem) as the libpq options startup parameter. Needed behind PgBouncer, which refuses that parameter unless it is in ignore_startup_parameters." }, { "name": "DB_APP_ROLE", "purpose": "auto (default) or off. Auto: after the migrations the entrypoint creates this database's role healthlog_app__, which may read and write rows and run the queue but not change the schema, and starts the app with it in place of the DATABASE_URL user. Off keeps the app on the DATABASE_URL user, for an operator who manages database roles.", "example": "off" } ] }, { "name": "APNs (iOS push)", "description": "All four core APNS_* vars together. Missing any disables APNs silently \u2014 the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic required row whenever 1-3 of the 4 are set.", "required": false, "allOrNone": true, "variables": [ { "name": "APNS_KEY_ID", "purpose": "10-char Key ID from Apple Developer." }, { "name": "APNS_TEAM_ID", "purpose": "10-char Team ID from Apple Developer." }, { "name": "APNS_BUNDLE_ID", "purpose": "iOS app bundle id, e.g. dev.healthlog.ios." }, { "name": "APNS_KEY", "purpose": "APNs auth key (.p8 contents). Use APNS_KEY for 12-factor env-block deploys; APNS_KEY_FILE is the filesystem alternative \u2014 at least one of the two must be set.", "anyOf": ["APNS_KEY", "APNS_KEY_FILE"] } ] }, { "name": "Deploy webhook", "description": "Coolify -> HealthLog deploy-result webhook. Without this, the auto-deploy success/failure feedback loop is broken.", "required": false, "variables": [ { "name": "DEPLOY_WEBHOOK_SECRET", "purpose": "Shared secret of POST /api/internal/deploy-webhook. 64 hex chars. A request authenticates by sending it in the X-Deploy-Webhook-Secret header (Coolify's stock sender; no replay protection), or by signing: X-Deploy-Webhook-Signature: sha256=, the timestamp within 5 minutes." }, { "name": "DEPLOY_LOGS_URL", "purpose": "URL of the deployment dashboard (e.g. the Coolify UI) linked in deploy-failure notifications. Optional; when unset the notification omits the link." }, { "name": "DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP", "purpose": "\"true\" (or \"1\") requires a fresh X-Deploy-Webhook-Timestamp (within 5 minutes) on a deploy-webhook request that authenticates with the secret header; one without it is refused with 401. 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.", "example": "true" }, { "name": "DEPLOY_WEBHOOK_REQUIRE_SIGNATURE", "purpose": "\"true\" (or \"1\") accepts only signed deploy-webhook requests (X-Deploy-Webhook-Signature with a fresh X-Deploy-Webhook-Timestamp); the secret header alone is refused with 401. The setting that stops a replay. Leave unset for Coolify's stock sender, which can only send the secret header.", "example": "true" } ] }, { "name": "TLS leaf pin monitor", "description": "Baseline for the TLS leaf SPKI-change alarm. The native client pins the served leaf certificate; without this the monitor logs the served pin but cannot alarm on a rotation. See docs/ops/tls-cert-pin.md.", "required": false, "variables": [ { "name": "TLS_LEAF_SPKI_PINS", "purpose": "Comma-separated known-good leaf SPKI pins, base64(sha256(DER subjectPublicKeyInfo)) \u2014 the same value the native client pins. Hold both the current and the next pin during a dual-pin renewal window." } ] }, { "name": "SMTP (email channel)", "description": "Operator SMTP transport for the email notification channel (v1.17.1). All of SMTP_HOST, SMTP_PORT, SMTP_FROM together enable the channel; SMTP_USER/SMTP_PASS are optional (unauthenticated relay). Missing any of the three core vars disables email silently \u2014 the channel is never offered and the Settings card hides itself.", "required": false, "allOrNone": true, "variables": [ { "name": "SMTP_HOST", "purpose": "SMTP server hostname, e.g. smtp.example.com." }, { "name": "SMTP_PORT", "purpose": "SMTP port. 587 for STARTTLS (SMTP_SECURE unset/false), 465 for implicit TLS (SMTP_SECURE=true)." }, { "name": "SMTP_FROM", "purpose": "From address for outbound mail, e.g. \"HealthLog \"." } ] }, { "name": "Notification targets on a private network", "description": "Operator grant for webhook (Gotify, Home Assistant, n8n) and ntfy targets on the operator's own network. Unset, a target that resolves to a private address is refused at send time and the test button reports private_origin_not_approved. Optional; public targets never need it.", "required": false, "variables": [ { "name": "NOTIFICATION_PRIVATE_ORIGINS", "purpose": "Comma-separated exact origins (scheme://host[:port], no path, no wildcard, no CIDR), e.g. \"https://gotify.example.com,http://ntfy.lan:8080\". Link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry is logged once and grants nothing." } ] }, { "name": "Dawarich on a private network", "description": "Operator grant for Dawarich instances on the operator's own network, read by the movement context of the Environment module. Unset, a Dawarich URL that resolves to a private address is refused at connect time with private_origin_not_approved. Optional; a public instance never needs it.", "required": false, "variables": [ { "name": "DAWARICH_PRIVATE_ORIGINS", "purpose": "Comma-separated exact origins (scheme://host[:port], no path, no wildcard, no CIDR), e.g. \"http://dawarich.lan:3000\". Link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry makes the connect form name it." } ] }, { "name": "Document picker", "description": "Operator grant for the document picker, which lets a person search their own Paperless-ngx or Papra instance and import picked documents. Unset, the picker is off. Optional.", "required": false, "variables": [ { "name": "DOCUMENT_SOURCE_ORIGINS", "purpose": "Comma-separated exact origins (scheme://host[:port], no path, no wildcard, no CIDR), e.g. \"https://paperless.example.com,http://papra.lan:1221\". Only listed origins are contacted, public or private; redirects are not followed; link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry is logged once and grants nothing." } ] }, { "name": "Off-host backups", "description": "Required ALL-OR-NONE: any of these without the rest disables backups silently.", "required": false, "allOrNone": true, "variables": [ { "name": "BACKUP_S3_ENDPOINT", "purpose": "S3-compatible endpoint URL." }, { "name": "BACKUP_S3_BUCKET", "purpose": "Bucket name." }, { "name": "BACKUP_S3_ACCESS_KEY", "purpose": "S3 access key." }, { "name": "BACKUP_S3_SECRET_KEY", "purpose": "S3 secret key." }, { "name": "BACKUP_ENCRYPTION_KEY", "purpose": "Separate AES key for the backup body \u2014 MUST NOT match ENCRYPTION_KEY." } ] }, { "name": "Database dumps", "description": "The nightly whole-database dump and the dump before migrations (docs/ops/disaster-recovery.md). Optional: each has a default, and the bundled compose file mounts the `backups` volume at the default directory.", "required": false, "variables": [ { "name": "DB_BACKUP_ENABLED", "purpose": "Set false to stop the nightly database dump. On when unset.", "example": "true" }, { "name": "DB_BACKUP_DIR", "purpose": "Directory the dumps are written to and restored from. Default /var/lib/healthlog/backups, the `backups` volume. In a container it must be a mount of its own: a directory in the container's layer is lost at the next update and counts as none. Without one the nightly dump only goes off-host and no dump is taken before migrations.", "example": "/var/lib/healthlog/backups" }, { "name": "DB_BACKUP_SCHEDULE", "purpose": "Five-field cron for the nightly dump, in Europe/Berlin time like every scheduled job. Default 30 1 * * * (01:30).", "example": "30 1 * * *" }, { "name": "DB_BACKUP_KEEP", "purpose": "How many nightly dumps stay in DB_BACKUP_DIR (default 7, at least 1). The last two dumps taken before a migration are kept besides.", "example": "7" }, { "name": "DB_BACKUP_BEFORE_MIGRATE", "purpose": "Set false to start without the dump the entrypoint takes before applying migrations. On when unset; a failed dump stops the start until it works or this is false.", "example": "true" } ] }, { "name": "Operational windows and cookie flags", "description": "Values the product states back to a user or that decide whether a session cookie is accepted. Optional: each has a documented default. They are listed so `pnpm check-env` reports them, and so the compose whitelist and the example files can be held in step with this manifest.", "required": false, "variables": [ { "name": "AUDIT_LOG_RETENTION_DAYS", "purpose": "How long audit rows survive, in days (365 by default). Published, not merely enforced: the shared-access activity feed and the dialog that ends somebody's access both state the number back to the user.", "example": "365" }, { "name": "ACCOUNT_DELETION_GRACE_DAYS", "purpose": "Days an account deletion, a managed-profile deletion or a data wipe stays undoable before the purge erases it (14 by default, 30 at most, 0 erases immediately). The account is locked at once. Published: the deletion dialogs and the Data & Privacy page state the number.", "example": "14" }, { "name": "SESSION_COOKIE_SECURE", "purpose": "Forces the Secure flag on every cookie the app issues. Unset, it tracks NODE_ENV. Set false for a LAN / VPN-only self-host served over plain HTTP, where the default makes login look broken.", "example": "false" }, { "name": "HEALTHLOG_PLATFORM", "purpose": "Where this server's environment is configured (compose, truenas, coolify, unraid, portainer). Shown back to the admin: the \"Back up your encryption key\" step opens on that platform's instructions. Unset means compose.", "example": "compose" }, { "name": "ENCRYPTION_KEY_CHECK", "purpose": "The start-up encryption key check. enforce (default) refuses to serve when the configured key does not open the stored data; warn only logs the finding, names it as a warning on /api/health, serves anyway and records no key until the check passes. A last resort for a refusal the operator is certain is false; it disables a safety check.", "example": "enforce" }, { "name": "TRUST_PROXY_HOPS", "purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For. Forwarding headers are read only from a peer whose own address is not public (loopback, private, CGNAT, unique-local, link-local, reserved, multicast).", "example": "1" }, { "name": "TRUSTED_PROXY_CIDRS", "purpose": "Comma-separated CIDRs (IPv4 or IPv6; a bare address is one host) the reverse proxy connects from. Set, only a connection from inside them may report the client address in X-Forwarded-For or X-Real-IP; every other connection is its own client. Unset, any non-public address may, which behind Docker Desktop, OrbStack or docker-proxy includes every caller of a published port. Same host: 127.0.0.1/32,::1/128; Docker network: the proxy's subnet. An entry that is not a CIDR lets no connection report an address and is named at boot.", "example": "127.0.0.1/32,::1/128" }, { "name": "TRUST_CF_CONNECTING_IP", "purpose": "\"1\" takes the client IP from Cloudflare's CF-Connecting-IP header, from every connection and before the peer check. Safe only when the origin accepts connections from Cloudflare alone (a Tunnel, or a firewall admitting only Cloudflare's ranges); otherwise any caller forges the address rate limits and the audit log record. Off by default.", "example": "1" }, { "name": "BACKUP_ENCRYPTION_PREVIOUS_KEYS", "purpose": "Comma-separated off-host backup keys that are no longer written with but may still be needed to read older objects. Set during a rotation of BACKUP_ENCRYPTION_KEY until the bucket's lifecycle rule has retired every object written under the old key.", "example": "" }, { "name": "BACKUP_MAX_STORED_MB", "purpose": "Largest stored backup of one account the app keeps, in megabytes of encrypted copy (2048 by default). A storage limit, independent of the app's memory: a backup past it fails with a message naming this variable, and the previous copy stays.", "example": "2048" } ] }, { "name": "Heap diagnostics", "description": "Opt-in heap snapshot for chasing an out-of-memory crash. Off by default; a snapshot holds decrypted health data and keys. See docs/ops/heap-diagnostics.md.", "required": false, "variables": [ { "name": "HEALTHLOG_HEAP_SNAPSHOT", "purpose": "Set to 1 to write one heap snapshot the first time the heap reaches 85 % of its limit.", "example": "1" }, { "name": "HEALTHLOG_DIAG_DIR", "purpose": "Directory the heap snapshot is written to. Default /tmp.", "example": "/tmp" } ] } ] }