Files
HealthLog/scripts/env-manifest.json
T
Marc-André Bombeck aebd7ea612 docs(deploy): describe the deploy webhook's signature and strict mode
The compose comment still called DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP a
requirement for a fresh timestamp header, which the route stopped
accepting in strict mode once requests could be signed, and the env
manifest named only the secret header and lacked the strict switch. Both
now say what the route checks: the secret header, which has no replay
protection, or X-Deploy-Webhook-Signature over the timestamp and body,
and that the strict setting takes signed requests only. The development
example says the same and points at the deploy runbook instead of a page
that no longer exists.
2026-10-25 16:00:35 +01:00

471 lines
24 KiB
JSON

{
"$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:<pw>@db:5432/healthlog?schema=public"
},
{
"name": "POSTGRES_PASSWORD",
"purpose": "Database password for the bundled `db` service in docker-compose.yml.",
"example": "<openssl rand -hex 32>"
},
{
"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 <issuer>/.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": "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 <all-or-none> 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=<HMAC-SHA256 under this secret of the X-Deploy-Webhook-Timestamp value, a dot and the raw body>, 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\") 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. 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 <noreply@example.com>\"."
}
]
},
{
"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": "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"
}
]
}
]
}