mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The runtime has always read GEOLITE2_DIR, but the variable was absent from the compose `environment:` whitelist, from both env examples, and from the docs. A variable that is not on that whitelist never reaches the container even when the operator's `.env` sets it, so the override existed in code and was unreachable from the documented path. Put it on the whitelist, document it in both examples and the manifest, and add `docs/self-hosting/geolite2.md`: fetch the databases with a free MaxMind account, mount the directory read-only, point the variable at it. The compose file carries the bind-mount line commented out beside the app service. The admin notification told the reader to set MAXMIND_LICENSE_KEY in this repository's Actions secrets — something nobody running the published image can do, about a fallback that sends their sign-in addresses to a third party. It now names the mount, names the online provider it is actually querying, and links the runbook. Six locales. It also no longer claims the carrier column stays empty; the online tier has filled the carrier from the ISP field since v1.25.8. A blank GEOLITE2_DIR now resolves to the default instead of the process working directory: compose substitutes an unset variable to the empty string, which is not nullish. Refs #659
314 lines
17 KiB
YAML
314 lines
17 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, so a backup of the database is
|
|
# a backup of everything. The optional GeoLite2 databases are either baked
|
|
# into the image at build time or mounted read-only from the host and
|
|
# pointed at with GEOLITE2_DIR — either way they are replaceable, not state.
|
|
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:-}"
|
|
# Directory the offline GeoLite2 databases are read from. Default
|
|
# `/opt/geolite2` — the path the published image bakes its own copy
|
|
# into, if the build had a MaxMind licence key. Point this at a
|
|
# directory you mount yourself (see the `volumes:` block below) to
|
|
# bring your own GeoLite2-City.mmdb + GeoLite2-ASN.mmdb, refreshed
|
|
# with `geoipupdate` on your own schedule. Listed here so an operator
|
|
# value in .env reaches the container (the `environment:` block is a
|
|
# whitelist). See docs/self-hosting/geolite2.md.
|
|
GEOLITE2_DIR: "${GEOLITE2_DIR:-/opt/geolite2}"
|
|
# 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:-}"
|
|
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
|
|
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
|
|
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
|
|
# hides password/passkey login entirely, but only takes effect when
|
|
# the three above are also fully set — a half-configured group never
|
|
# locks every user out. It IS enforced server-side, and there is no
|
|
# OIDC flow for the native iOS client yet — enabling it locks iOS
|
|
# users out of sign-in entirely. Listed here so operator values in
|
|
# .env reach the container (the `environment:` block is a whitelist).
|
|
OIDC_ISSUER_URL: "${OIDC_ISSUER_URL:-}"
|
|
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}"
|
|
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
|
|
OIDC_SCOPES: "${OIDC_SCOPES:-}"
|
|
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
|
|
OIDC_ONLY: "${OIDC_ONLY:-}"
|
|
# 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:-}"
|
|
# Bring your own GeoLite2 databases. The published image only carries
|
|
# MMDBs if the build had a MaxMind licence key, so on `:latest` the
|
|
# offline tier is usually absent and every login IP goes to the online
|
|
# provider instead. Create a free MaxMind account, pull
|
|
# GeoLite2-City.mmdb + GeoLite2-ASN.mmdb with `geoipupdate`, mount the
|
|
# directory read-only, and set GEOLITE2_DIR to the container path.
|
|
# Steps: docs/self-hosting/geolite2.md.
|
|
#
|
|
# volumes:
|
|
# - ./geolite2:/var/lib/geolite2:ro
|
|
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/health || 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&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}"
|
|
# 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:
|