mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
The recurring no-briefing day had a chain of causes, closed end to end: Every failure path in the nightly pregenerate + the comprehensive generator now annotates a queryable cause (several classes — features-error, payload-too-large, aborted, generator-failed — previously bumped a counter with zero trace). A failed nightly warm enqueues one bounded retry ~45 minutes later (singleton per user, existing budgets apply), so a transient provider hiccup heals before morning. A cached payload that carries NO briefing no longer satisfies the 24h POST short-circuit nor the unchanged-content hash gate — a once-stripped briefing could previously block its own regeneration indefinitely. The card surfaces the omission reason persistently (grounding strip, generation failure, not attempted) with a forcing retry. Documentation corrected against code truth: Withings credentials are per-user (the documented env path never existed; the dead example entries and manifest rows are gone, and WITHINGS_WEBHOOK_SECRET is now actually whitelisted in compose — stock deploys could never deliver it); the classic Fitbit portal no longer accepts new registrations; the backup-restore example no longer mints a fresh encryption key and uses pnpm dlx tsx; the TLS guide describes the real client model (two CA-level SPKI pins, any-match, leaf renewals need no build); scaling/admin references fixed; the operator-shared subscription provider is documented.
278 lines
15 KiB
YAML
278 lines
15 KiB
YAML
# HealthLog — two-container stack (app + Postgres 16).
|
|
#
|
|
# First run (clean host):
|
|
# cp .env.example .env
|
|
# echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
|
|
# echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
|
# echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
|
# docker compose up -d
|
|
# # then open http://<host>:3000 — the first registered user becomes admin.
|
|
#
|
|
# LAN / plain-HTTP self-hosts (NAS, homelab, Tailscale): set
|
|
# SESSION_COOKIE_SECURE=false
|
|
# in .env, otherwise the session cookie carries the `Secure` flag over HTTP
|
|
# and login silently fails. This is the single most common self-host snag.
|
|
# Leave it unset (or true) only when a TLS reverse proxy serves HTTPS.
|
|
#
|
|
# Persistent state lives in exactly one named volume (`pgdata`, Postgres'
|
|
# data dir). The app container is stateless — GeoLite2 MMDBs are baked into
|
|
# the image — so a backup of the database is a backup of everything.
|
|
services:
|
|
# Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing
|
|
# self-host deployments stay working with `docker compose up`.
|
|
# For horizontal scaling switch to the web/worker split below
|
|
# (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker).
|
|
app:
|
|
# Tracks the GHCR `:latest` multi-arch image by default.
|
|
# `pull_policy: always` is the load-bearing line — without it,
|
|
# Docker keeps a stale `:latest` digest cached on the host and
|
|
# skips the registry round-trip on `compose up`, which is what kept
|
|
# Coolify re-deploying the previous image after every tag push.
|
|
# With pull_policy=always the compose-up always re-checks the
|
|
# registry digest and pulls when GHCR has a fresher manifest.
|
|
#
|
|
# HEALTHLOG_IMAGE_REF pins the image deterministically without
|
|
# editing this file: set it in the compose `.env` (or the Coolify
|
|
# env vars) to `:vX.Y.Z` for a tag or `@sha256:<digest>` for an
|
|
# immutable digest reference (see docs/ops/deploy.md, "Digest
|
|
# pinning"; scripts/promote-digest.ts resolves + verifies the
|
|
# digest after a release). Unset, everything behaves exactly as
|
|
# before — `:latest` plus pull_policy.
|
|
image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
|
|
pull_policy: always
|
|
container_name: healthlog-app
|
|
# The app runs as the unprivileged `nextjs` user and never needs a
|
|
# setuid/setgid escalation; blocking privilege gain caps what a
|
|
# compromised process inside the container can do.
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
ports:
|
|
- "3000:3000"
|
|
environment:
|
|
# connection_limit is set explicitly: Prisma's own default is
|
|
# cpu_count*2+1, which starves the combined web+worker process
|
|
# (HEALTHLOG_PROCESS_TYPE=all) under background AI/rollup load. Default
|
|
# 20 suits a small host; raise DB_CONNECTION_LIMIT on a busy one (keep it
|
|
# below the database's max_connections, leaving headroom for pg-boss,
|
|
# migrations, and backups). pool_timeout is the seconds a query waits for
|
|
# a free connection before erroring.
|
|
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}"
|
|
NODE_ENV: production
|
|
# Encryption at rest (AES-256-GCM). Generate each secret with
|
|
# `openssl rand -hex 32` (a 64-char hex string = 32 bytes). ENCRYPTION_KEY
|
|
# is the single-key path and is all most self-hosts need. The map form
|
|
# (ENCRYPTION_KEYS={"v1":"<hex>","v2":"<hex>"} + ENCRYPTION_ACTIVE_KEY_ID=v2)
|
|
# is for key rotation — see docs/ops/encryption-key-rotation.md. Keep these
|
|
# values stable and backed up: lose them and the encrypted columns are
|
|
# unrecoverable.
|
|
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
|
ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
|
ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
|
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
|
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
|
|
APP_URL: "${APP_URL:-http://localhost:3000}"
|
|
# Session-cookie Secure-flag opt-out. SET THIS TO false FOR LAN / plain-HTTP
|
|
# self-hosts (http://<host>:3000 with no TLS reverse proxy) — otherwise the
|
|
# session cookie carries the `Secure` flag, the browser drops it over HTTP,
|
|
# and login silently fails. Unset → the flag tracks NODE_ENV=production
|
|
# (i.e. on); set to true to force it. Forwarded explicitly so an operator
|
|
# value in .env reaches the Node process (the `environment:` block is a
|
|
# whitelist — vars not listed here don't propagate even though
|
|
# docker-compose reads .env for `${VAR}` substitution at compose-up time).
|
|
SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}"
|
|
NATIVE_CANVAS: "${NATIVE_CANVAS:-}"
|
|
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
|
|
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
|
|
# the real visitor IP is in `cf-connecting-ip`; set
|
|
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
|
|
# default — a direct-exposed host must not trust a forgeable header).
|
|
# IP_GEO_LOOKUP_URL overrides the default ipwho.is provider (HTTPS by
|
|
# default); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress.
|
|
# IP_GEO_ALLOW_INSECURE=true permits a plain-HTTP provider URL (e.g. the
|
|
# free ip-api.com endpoint) — off by default, an explicit operator choice.
|
|
# Listed here so operator values in .env reach the container (the
|
|
# `environment:` block is a whitelist — vars not listed never propagate).
|
|
TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}"
|
|
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
|
|
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
|
|
IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}"
|
|
# all = bundle web + worker in this container (default, single-host)
|
|
# web = serve HTTP only; pair with a separate app-worker service
|
|
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
|
|
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
|
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
|
BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
|
|
BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
|
|
BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
|
BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
|
BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}"
|
|
# Per-export ceiling on MedicationAdministration rows in the FHIR
|
|
# health-record Bundle. Bounded 1..50000, default 5000. Listed here so
|
|
# an operator override in .env actually reaches the container (the
|
|
# `environment:` block is a whitelist).
|
|
FHIR_MAX_MEDICATION_ADMINISTRATIONS: "${FHIR_MAX_MEDICATION_ADMINISTRATIONS:-}"
|
|
# WHOOP integration (optional). The app client id/secret are per-user
|
|
# BYO-keys stored encrypted in the DB, so there is no
|
|
# WHOOP_CLIENT_ID/SECRET env. WHOOP_WEBHOOK_SECRET is the instance-level
|
|
# path-segment + HMAC-verification secret for the webhook;
|
|
# WHOOP_REDIRECT_URI overrides the derived
|
|
# `${NEXT_PUBLIC_APP_URL}/api/whoop/callback` when the public URL
|
|
# differs. Listed here so an operator override in .env reaches the
|
|
# container (the `environment:` block is a whitelist).
|
|
WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}"
|
|
WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}"
|
|
# v1.17.0 (F4) — Polar AccessLink + Oura Cloud OAuth (shared app from
|
|
# env). Both pairs together enable each connect button; an unset pair
|
|
# leaves that integration disabled. The optional *_REDIRECT_URI overrides
|
|
# the derived `${NEXT_PUBLIC_APP_URL}/api/{polar,oura}/callback`. Listed
|
|
# here so operator values in .env reach the container (the `environment:`
|
|
# block is a whitelist).
|
|
POLAR_CLIENT_ID: "${POLAR_CLIENT_ID:-}"
|
|
POLAR_CLIENT_SECRET: "${POLAR_CLIENT_SECRET:-}"
|
|
POLAR_REDIRECT_URI: "${POLAR_REDIRECT_URI:-}"
|
|
OURA_CLIENT_ID: "${OURA_CLIENT_ID:-}"
|
|
OURA_CLIENT_SECRET: "${OURA_CLIENT_SECRET:-}"
|
|
OURA_REDIRECT_URI: "${OURA_REDIRECT_URI:-}"
|
|
# v1.28.x — Strava OAuth (shared app from env; per-user BYO keys override).
|
|
# Both together enable the connect button; an unset pair leaves it
|
|
# disabled. STRAVA_REDIRECT_URI overrides the derived
|
|
# `${NEXT_PUBLIC_APP_URL}/api/strava/callback`. Listed here so operator
|
|
# values in .env reach the container (the `environment:` block is a
|
|
# whitelist).
|
|
STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}"
|
|
STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}"
|
|
STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}"
|
|
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
|
|
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
|
|
# deployment-dashboard URL (e.g. the Coolify UI) linked in
|
|
# deploy-failure notifications. Listed here so operator values in
|
|
# .env reach the container (the `environment:` block is a whitelist).
|
|
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
|
|
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
|
|
# Optional replay hardening: when "true", the deploy webhook
|
|
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
|
|
# See docs/ops/deploy.md.
|
|
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
|
|
# TLS leaf SPKI-change alarm baseline (optional). The native client
|
|
# pins the served TLS leaf; this is the known-good pin set the monitor
|
|
# alarms against on a rotation. Comma-separated for the dual-pin
|
|
# renewal window. Listed here so an operator value in .env reaches the
|
|
# container (the `environment:` block is a whitelist). See
|
|
# docs/ops/tls-cert-pin.md.
|
|
TLS_LEAF_SPKI_PINS: "${TLS_LEAF_SPKI_PINS:-}"
|
|
# Local-AI SSRF opt-in (optional). The local AI provider rejects
|
|
# private/internal hosts by default; set this to an exact host allowlist
|
|
# (e.g. "ollama.lan,10.0.0.5") or "true" for any private host, intended
|
|
# for a self-hosted Ollama / LM Studio. Listed here so an operator value
|
|
# in .env reaches the container (the `environment:` block is a whitelist).
|
|
# See docs/integrations/ai-providers.md.
|
|
ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}"
|
|
# Daily-briefing output-token ceiling (optional; default 2500, clamped
|
|
# 500-8000). Raise when a verbose model's briefing JSON is cut off.
|
|
# Listed here so an operator value in .env reaches the container (the
|
|
# `environment:` block is a whitelist). See docs/integrations/ai-providers.md.
|
|
INSIGHTS_MAX_TOKENS: "${INSIGHTS_MAX_TOKENS:-}"
|
|
# v1.17.1 — SMTP transport for the email notification channel (optional,
|
|
# all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE
|
|
# toggles implicit TLS). Listed here so operator values in .env reach the
|
|
# container (the `environment:` block is a whitelist — vars not listed
|
|
# never propagate from .env, even with `${VAR}` substitution at
|
|
# compose-up). Per-user recipient is stored in the DB, not here.
|
|
SMTP_HOST: "${SMTP_HOST:-}"
|
|
SMTP_PORT: "${SMTP_PORT:-}"
|
|
SMTP_FROM: "${SMTP_FROM:-}"
|
|
SMTP_USER: "${SMTP_USER:-}"
|
|
SMTP_PASS: "${SMTP_PASS:-}"
|
|
SMTP_SECURE: "${SMTP_SECURE:-}"
|
|
# v1.17.1 — Web Push VAPID keys (optional). The admin Settings panel stores
|
|
# these in the DB (the supported path); these env vars are the fallback the
|
|
# loader reads when the DB pair is unset. Listed here so the documented env
|
|
# path reaches the container under the bundled compose. See
|
|
# docs/self-hosting/notifications.md.
|
|
VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
|
|
VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
|
|
VAPID_SUBJECT: "${VAPID_SUBJECT:-}"
|
|
# APNs for a self-hosted native iOS build (optional, env-only — no admin UI
|
|
# for the .p8). APNS_KEY_B64 is the escape-free base64 form preferred over
|
|
# APNS_KEY/APNS_KEY_FILE; the key must be scoped "Sandbox & Production".
|
|
# PWA users need none of this (Web Push covers them). See
|
|
# docs/self-hosting/notifications.md.
|
|
APNS_KEY_B64: "${APNS_KEY_B64:-}"
|
|
APNS_KEY_ID: "${APNS_KEY_ID:-}"
|
|
APNS_TEAM_ID: "${APNS_TEAM_ID:-}"
|
|
APNS_BUNDLE_ID: "${APNS_BUNDLE_ID:-}"
|
|
# Set "true" only if your own iOS build carries Apple's Critical Alerts
|
|
# entitlement; otherwise urgent alerts use the time-sensitive level.
|
|
APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}"
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test:
|
|
[
|
|
"CMD-SHELL",
|
|
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/version || exit 1",
|
|
]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 30s
|
|
|
|
# Optional dedicated worker container — uncomment + set
|
|
# HEALTHLOG_PROCESS_TYPE=web on `app` to split the deployment.
|
|
# See docs/self-hosting/scaling.md for the full guide.
|
|
#
|
|
# app-worker:
|
|
# image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
|
|
# build:
|
|
# context: .
|
|
# dockerfile: Dockerfile
|
|
# container_name: healthlog-worker
|
|
# environment:
|
|
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
|
# NODE_ENV: production
|
|
# ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
|
# ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
|
# ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
|
# API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
|
# APP_URL: "${APP_URL:-http://localhost:3000}"
|
|
# HEALTHLOG_PROCESS_TYPE: "worker"
|
|
# BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
|
# BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
|
# BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
|
|
# BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
|
|
# BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
|
# BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
|
# depends_on:
|
|
# db:
|
|
# condition: service_healthy
|
|
# restart: unless-stopped
|
|
|
|
db:
|
|
# Pinned to the concrete minor instead of the floating `16-alpine`
|
|
# alias so a database update is always a deliberate, reviewable
|
|
# change (at pin time `16-alpine` resolved to this same digest, so
|
|
# existing deployments see no image change). Minor releases share
|
|
# the on-disk format — bumping this pin is safe without a dump /
|
|
# restore; a major-version bump (17+) is not.
|
|
image: postgres:16.14-alpine
|
|
container_name: healthlog-db
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
environment:
|
|
POSTGRES_USER: healthlog
|
|
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
|
POSTGRES_DB: healthlog
|
|
volumes:
|
|
- pgdata:/var/lib/postgresql/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U healthlog"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 5
|
|
restart: unless-stopped
|
|
|
|
volumes:
|
|
pgdata:
|