mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
847 lines
47 KiB
YAML
847 lines
47 KiB
YAML
# HealthLog — two-container stack (app + Postgres 18).
|
||
#
|
||
# 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 the named volume `postgres-data` (Postgres' data).
|
||
# The app container is stateless, so a backup of the database is a backup of
|
||
# everything. `backups` holds the database dumps the app takes every night and
|
||
# before every migration (docs/ops/disaster-recovery.md); keep a copy of them
|
||
# off this host, or set the off-host bucket below. `pgdata` is where Postgres 16 kept the data up to
|
||
# v1.45; on an install that has it, `db-upgrade` moves it into Postgres 18 once
|
||
# and then leaves it alone as the way back (docs/ops/postgres-major-upgrade.md). 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.
|
||
|
||
# Shared web/worker environment. `app` and the optional `app-worker` below
|
||
# both merge this anchor with `<<: *healthlog_env` instead of each keeping
|
||
# their own copy of the list. A hand-copied second list is exactly how the
|
||
# app-worker example drifted before: `app` grew to 62 variables over time
|
||
# while the worker example stayed at 14, missing every SMTP_*, VAPID_*,
|
||
# APNS_*, INSIGHTS_MAX_TOKENS and ALLOW_LOCAL_AI_PRIVATE_HOSTS entry — so a
|
||
# deployed worker (the process that sends notifications and runs the AI
|
||
# jobs) silently couldn't email, push, or send APNs, and had no local-AI
|
||
# host allowlist. One shared block makes that drift structurally
|
||
# impossible. HEALTHLOG_PROCESS_TYPE is the one value that's meant to
|
||
# differ per service and is overridden after the merge.
|
||
x-healthlog-env: &healthlog_env
|
||
# 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:-}"
|
||
# Read-only showcase instance. DEMO_MODE=true makes the proxy refuse every
|
||
# mutation except login and paints the demo banner. Leave it unset on a
|
||
# real self-host — it is a public-demo switch, not a safety belt.
|
||
DEMO_MODE: "${DEMO_MODE:-}"
|
||
# Statement timeout applied to every database session, in milliseconds
|
||
# (default 60000; 0 disables it and restores the unbounded behaviour).
|
||
# Raise it if a very large account's analytics fallback is being cut off.
|
||
DATABASE_STATEMENT_TIMEOUT_MS: "${DATABASE_STATEMENT_TIMEOUT_MS:-}"
|
||
# Postgres work_mem for every app session (default 16MB).
|
||
DATABASE_WORK_MEM: "${DATABASE_WORK_MEM:-}"
|
||
# 1 sends neither of the two settings above (they ride the libpq `options`
|
||
# startup parameter, which PgBouncer refuses by default).
|
||
DATABASE_SESSION_OPTIONS_DISABLED: "${DATABASE_SESSION_OPTIONS_DISABLED:-}"
|
||
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
|
||
# Overrides the derived `${NEXT_PUBLIC_APP_URL}/api/withings/callback`;
|
||
# set it when the URL registered with Withings differs from the app's own
|
||
# public URL. The WHOOP / Polar / Oura / Strava equivalents are below.
|
||
WITHINGS_REDIRECT_URI: "${WITHINGS_REDIRECT_URI:-}"
|
||
# Path-segment + HMAC secret for the Telegram bot webhook. Set it to the
|
||
# same value you register with BotFather; without it the settings page
|
||
# cannot install the webhook and inbound updates are refused.
|
||
TELEGRAM_WEBHOOK_SECRET: "${TELEGRAM_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 the origin accepts connections from
|
||
# Cloudflare alone (a Tunnel, or a firewall admitting only Cloudflare's
|
||
# ranges): the header is believed from every connection (off by default;
|
||
# see docs/self-hosting/reverse-proxy.md).
|
||
# 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_PROXY_HOPS is the 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. Wrong value and the app refuses to read XFF and
|
||
# collapses every anonymous caller into one rate-limit bucket. Listed here
|
||
# so an operator value in .env reaches the container (the `environment:`
|
||
# block is a whitelist).
|
||
TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"
|
||
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:-}"
|
||
# Outbound requests an operator can turn off (1 / true / yes). Both are on
|
||
# by default. PASSWORD_BREACH_CHECK_DISABLED stops the HaveIBeenPwned range
|
||
# query made when a password is set (only the first five characters of the
|
||
# password's SHA-1 are sent). UPDATE_CHECK_DISABLED stops the daily request
|
||
# to the GitHub releases API behind the About page and the admin version
|
||
# tile. Listed here so an operator value in .env reaches the container.
|
||
PASSWORD_BREACH_CHECK_DISABLED: "${PASSWORD_BREACH_CHECK_DISABLED:-}"
|
||
UPDATE_CHECK_DISABLED: "${UPDATE_CHECK_DISABLED:-}"
|
||
# 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}"
|
||
# MaxMind licence key for the RUNTIME GeoLite2 fetch (issue #659). Set this
|
||
# and the worker downloads GeoLite2-City + GeoLite2-ASN itself into
|
||
# GEOLITE2_DIR on the next boot and monthly thereafter — no image rebuild,
|
||
# no manual mount. Get a free key at
|
||
# https://www.maxmind.com/en/geolite2/signup (My Account -> Manage License
|
||
# Keys). Leave it unset and nothing is fetched (mounting your own databases
|
||
# or IP_GEO_LOOKUP_DISABLED=1 both still work). Listed here so an operator
|
||
# value in .env reaches the container (the `environment:` block is a
|
||
# whitelist). See docs/self-hosting/geolite2.md.
|
||
MAXMIND_LICENSE_KEY: "${MAXMIND_LICENSE_KEY:-}"
|
||
# 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}"
|
||
# compose | truenas | coolify | unraid | portainer. Only chooses which
|
||
# instructions the admin's "Back up your encryption key" step shows first.
|
||
HEALTHLOG_PLATFORM: "${HEALTHLOG_PLATFORM:-}"
|
||
# enforce (default) | warn. The start-up encryption key check refuses to
|
||
# serve when the key does not open the stored data; warn only logs it and
|
||
# serves anyway. A last resort for a refusal you are certain is false: it
|
||
# disables a safety check. See docs/self-hosting/getting-started.md.
|
||
ENCRYPTION_KEY_CHECK: "${ENCRYPTION_KEY_CHECK:-}"
|
||
# The whole-database dump (docs/ops/disaster-recovery.md). Nightly at
|
||
# DB_BACKUP_SCHEDULE (five-field cron in Europe/Berlin time, default
|
||
# "30 1 * * *") into DB_BACKUP_DIR (default /var/lib/healthlog/backups, the
|
||
# `backups` volume below), keeping DB_BACKUP_KEEP daily dumps (default 7)
|
||
# and the last two taken before a migration. With the off-host bucket set,
|
||
# each nightly dump is also uploaded under `database/`, sealed with
|
||
# BACKUP_ENCRYPTION_KEY. DB_BACKUP_ENABLED=false stops the nightly dump;
|
||
# DB_BACKUP_BEFORE_MIGRATE=false skips the dump before migrations. Change
|
||
# DB_BACKUP_DIR only together with the volume mount.
|
||
DB_BACKUP_ENABLED: "${DB_BACKUP_ENABLED:-}"
|
||
DB_BACKUP_DIR: "${DB_BACKUP_DIR:-}"
|
||
DB_BACKUP_SCHEDULE: "${DB_BACKUP_SCHEDULE:-}"
|
||
DB_BACKUP_KEEP: "${DB_BACKUP_KEEP:-}"
|
||
DB_BACKUP_BEFORE_MIGRATE: "${DB_BACKUP_BEFORE_MIGRATE:-}"
|
||
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||
BACKUP_ENCRYPTION_PREVIOUS_KEYS: "${BACKUP_ENCRYPTION_PREVIOUS_KEYS:-}"
|
||
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}"
|
||
# Largest stored backup of one account, in MB of encrypted copy (default
|
||
# 2048). A storage limit, not a memory one; see .env.production.example.
|
||
BACKUP_MAX_STORED_MB: "${BACKUP_MAX_STORED_MB:-}"
|
||
# How long the audit trail is kept, in days. Default 365, floor 7 (a value
|
||
# below the floor is ignored rather than obeyed, so a window set in seconds
|
||
# by mistake cannot empty a fresh table). It is not only a retention knob:
|
||
# the shared-access panel PUBLISHES this number — "who entered what stays
|
||
# answerable for N days" is on the dialog that ends somebody's access, and
|
||
# the activity view states the same window. An instance running 90 says 90,
|
||
# so the var has to actually reach the container. Listed here for the reason
|
||
# the block exists: `environment:` is a whitelist, and a value in .env that
|
||
# is not named here never arrives.
|
||
AUDIT_LOG_RETENTION_DAYS: "${AUDIT_LOG_RETENTION_DAYS:-}"
|
||
ACCOUNT_DELETION_GRACE_DAYS: "${ACCOUNT_DELETION_GRACE_DAYS:-}"
|
||
# How long Coach conversations are kept, in days (default 365, floor 30).
|
||
# Like the audit window this number is PUBLISHED to the user on the Data &
|
||
# Privacy page, so an instance running 90 has to be able to say 90.
|
||
COACH_MESSAGE_RETENTION_DAYS: "${COACH_MESSAGE_RETENTION_DAYS:-}"
|
||
# How long the host CPU/memory samples behind the admin charts are kept,
|
||
# in days (default 7). Raise it for a longer trend, lower it on a small
|
||
# disk.
|
||
HOST_METRIC_RETENTION_DAYS: "${HOST_METRIC_RETENTION_DAYS:-}"
|
||
# 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, at the cost of table growth.
|
||
DENSE_INTRADAY_RETENTION_ENABLED: "${DENSE_INTRADAY_RETENTION_ENABLED:-}"
|
||
# Consecutive failures before an integration is reported as persistently
|
||
# failing and notified about (default 3, minimum 1). Raise it if a flaky
|
||
# upstream is noisy.
|
||
INTEGRATION_FAILURE_ALERT_THRESHOLD: "${INTEGRATION_FAILURE_ALERT_THRESHOLD:-}"
|
||
# 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:-}"
|
||
# Fitbit + Google Health are per-user BYO-key integrations, so there is no
|
||
# client id/secret env for either. These override the derived
|
||
# `${NEXT_PUBLIC_APP_URL}/api/{fitbit,google-health}/callback` when the URL
|
||
# registered with the provider differs from the app's public URL. Both are
|
||
# asserted same-origin with NEXT_PUBLIC_APP_URL, so they cannot redirect
|
||
# the authorization code somewhere else.
|
||
FITBIT_REDIRECT_URI: "${FITBIT_REDIRECT_URI:-}"
|
||
GOOGLE_HEALTH_REDIRECT_URI: "${GOOGLE_HEALTH_REDIRECT_URI:-}"
|
||
# How many years back the Google Health history backfill reads (a whole
|
||
# number from 1 to 100; 20 when unset). A data type whose history goes
|
||
# further ends there, and the integration card says so.
|
||
GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS: "${GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS:-}"
|
||
# Nightscout origins on your own network that the per-user connection
|
||
# validator should accept, comma-separated (e.g. "http://nightscout.lan").
|
||
# Without it a LAN/private Nightscout URL is refused as an SSRF target.
|
||
NIGHTSCOUT_PRIVATE_ORIGINS: "${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"). Without it a LAN/private Dawarich URL is
|
||
# refused as an SSRF target; a public instance needs nothing here.
|
||
DAWARICH_PRIVATE_ORIGINS: "${DAWARICH_PRIVATE_ORIGINS:-}"
|
||
# Webhook (Gotify, Home Assistant, n8n) and ntfy targets on your own
|
||
# network, comma-separated exact origins (e.g. "https://gotify.example.com,
|
||
# http://ntfy.lan:8080"). Without it a target that resolves to a private
|
||
# address is refused at send time as an SSRF target. Link-local, metadata
|
||
# and unspecified addresses stay refused even when listed. See
|
||
# docs/self-hosting/notifications.md.
|
||
NOTIFICATION_PRIVATE_ORIGINS: "${NOTIFICATION_PRIVATE_ORIGINS:-}"
|
||
# Paperless-ngx and Papra instances the document picker may reach,
|
||
# comma-separated exact origins (e.g. "https://paperless.example.com,
|
||
# http://papra.lan:1221"). Unset, the picker is off. Only listed origins are
|
||
# ever contacted, public or private; redirects are not followed and
|
||
# link-local, metadata and unspecified addresses stay refused even when
|
||
# listed. See docs/self-hosting/document-sources.md.
|
||
DOCUMENT_SOURCE_ORIGINS: "${DOCUMENT_SOURCE_ORIGINS:-}"
|
||
# Open-Meteo endpoints for the environment/weather correlation. Default to
|
||
# the hosted service; point them at your own Open-Meteo deployment to keep
|
||
# the coordinate lookups off a third party.
|
||
OPENMETEO_BASE_URL: "${OPENMETEO_BASE_URL:-}"
|
||
OPENMETEO_GEOCODING_URL: "${OPENMETEO_GEOCODING_URL:-}"
|
||
# Air quality, pollen and UV (CAMS via Open-Meteo). Same hosted default and
|
||
# self-host option as above; ENVIRONMENT_AIR_QUALITY_DISABLED=1 stops the
|
||
# air-quality requests for the whole instance.
|
||
OPENMETEO_AIR_QUALITY_URL: "${OPENMETEO_AIR_QUALITY_URL:-}"
|
||
ENVIRONMENT_AIR_QUALITY_DISABLED: "${ENVIRONMENT_AIR_QUALITY_DISABLED:-}"
|
||
# 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). POST /api/internal/deploy-webhook
|
||
# authenticates either with DEPLOY_WEBHOOK_SECRET in the
|
||
# X-Deploy-Webhook-Secret header (what Coolify's stock sender can do; no
|
||
# replay protection) or with X-Deploy-Webhook-Signature: sha256=<HMAC of
|
||
# "<X-Deploy-Webhook-Timestamp>.<body>" under the secret>, the timestamp
|
||
# within 5 minutes. DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true accepts signed
|
||
# requests only. 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). See docs/ops/deploy.md.
|
||
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
|
||
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
|
||
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:-}"
|
||
# AI endpoints on your own network (optional): a self-hosted Ollama,
|
||
# LM Studio or LiteLLM gateway, comma-separated exact origins (e.g.
|
||
# "http://ollama.lan:11434,http://10.0.0.5:4000"). Without it an AI base
|
||
# URL on a private address is refused. Link-local, metadata and
|
||
# unspecified addresses stay refused even when listed. See
|
||
# docs/integrations/ai-providers.md.
|
||
AI_PRIVATE_ORIGINS: "${AI_PRIVATE_ORIGINS:-}"
|
||
# Older form of the above, kept for existing installs: an exact host list
|
||
# (e.g. "ollama.lan,10.0.0.5") still works. "true" is deprecated: it now
|
||
# covers only the admin AI key and admin accounts' own AI settings, and
|
||
# will be removed in a later release; move to AI_PRIVATE_ORIGINS.
|
||
ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}"
|
||
# 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 only the admin
|
||
# setting, never the per-user BYO-key posture.
|
||
ADMIN_AI_BASE_URL_ALLOWLIST: "${ADMIN_AI_BASE_URL_ALLOWLIST:-}"
|
||
# Codex (ChatGPT sign-in) model slugs. Set CODEX_MODEL to pin one slug
|
||
# ahead of the built-in chain, or CODEX_MODEL_FALLBACK_CHAIN (comma-
|
||
# separated) to replace the chain outright. Reach for these when the
|
||
# provider rotates a slug and every request starts coming back 503 —
|
||
# without them a rotation needs a new release to fix.
|
||
CODEX_MODEL: "${CODEX_MODEL:-}"
|
||
CODEX_MODEL_FALLBACK_CHAIN: "${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 an explicit operator opt-in. Set to "1" to enable.
|
||
COACH_EXPERIMENT_VERDICT: "${COACH_EXPERIMENT_VERDICT:-}"
|
||
# 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:-}"
|
||
# Per-user hourly ceiling for POST /api/insights/generate (optional;
|
||
# default 10). Was documented but missing from this whitelist, so an
|
||
# operator value never reached the container. See
|
||
# docs/integrations/ai-providers.md.
|
||
INSIGHTS_RATE_LIMIT_PER_HOUR: "${INSIGHTS_RATE_LIMIT_PER_HOUR:-}"
|
||
# Per-user hourly ceiling for the document-AI actions (read/index, extract,
|
||
# suggest, summarise — one shared bucket; optional; default 6, clamped
|
||
# 1-1000). Raise on a self-host with a generous provider budget, lower on a
|
||
# tight one. Listed here so an operator value in .env reaches the container
|
||
# (the `environment:` block is a whitelist). See
|
||
# docs/integrations/ai-providers.md.
|
||
DOCUMENT_AI_LIMIT_PER_HOUR: "${DOCUMENT_AI_LIMIT_PER_HOUR:-}"
|
||
# Minimum hours between two status-card regenerations after a sync
|
||
# (optional; default 6, whole hours 1-48; hand entries keep one hour). The
|
||
# value under Admin > Coach wins. Listed here so an operator value in .env
|
||
# reaches the container. See docs/integrations/ai-providers.md.
|
||
AI_SYNC_REGEN_GAP_HOURS: "${AI_SYNC_REGEN_GAP_HOURS:-}"
|
||
# Hourly upload ceiling for each narrow document-upload token (a Paperless
|
||
# workflow, the import script; optional; default 120, clamped 1-1000).
|
||
# Uploads from the web app and the phone keep their own bucket. Listed here
|
||
# so an operator value in .env reaches the container. See
|
||
# docs/integrations/document-import.md.
|
||
DOCUMENT_UPLOAD_LIMIT_PER_HOUR: "${DOCUMENT_UPLOAD_LIMIT_PER_HOUR:-}"
|
||
# Per-user hourly ceiling for lab-report scans (photo/PDF and browser-OCR
|
||
# text share one bucket; optional; default 6, clamped 1-1000). A scan that
|
||
# fails before the provider is called hands its slot back. Listed here so an
|
||
# operator value in .env reaches the container. See
|
||
# docs/integrations/ai-providers.md.
|
||
LABS_OCR_LIMIT_PER_HOUR: "${LABS_OCR_LIMIT_PER_HOUR:-}"
|
||
# 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:-}"
|
||
# The other two key sources, in precedence order after APNS_KEY_B64:
|
||
# APNS_KEY is the raw PEM body, APNS_KEY_FILE a path to the .p8 inside the
|
||
# container (mount it yourself). Both are documented in
|
||
# .env.production.example and in the manifest check-env reads, and both
|
||
# were absent from this whitelist — so an operator who followed the
|
||
# documented precedence chain got a silently disabled APNs.
|
||
APNS_KEY: "${APNS_KEY:-}"
|
||
APNS_KEY_FILE: "${APNS_KEY_FILE:-}"
|
||
APNS_KEY_ID: "${APNS_KEY_ID:-}"
|
||
APNS_TEAM_ID: "${APNS_TEAM_ID:-}"
|
||
APNS_BUNDLE_ID: "${APNS_BUNDLE_ID:-}"
|
||
# Force the production APNs gateway. Normally derived from NODE_ENV; set
|
||
# "true" when a production-signed build has to reach the production
|
||
# gateway from a non-production deployment.
|
||
APNS_PRODUCTION: "${APNS_PRODUCTION:-}"
|
||
# 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:-}"
|
||
# Structured logging. 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="false" strips stack traces from
|
||
# the emitted events.
|
||
LOG_LEVEL: "${LOG_LEVEL:-}"
|
||
LOG_SAMPLE_RATE: "${LOG_SAMPLE_RATE:-}"
|
||
LOG_SLOW_THRESHOLD_MS: "${LOG_SLOW_THRESHOLD_MS:-}"
|
||
LOG_INCLUDE_STACK: "${LOG_INCLUDE_STACK:-}"
|
||
# Heap diagnostics, off by default. HEALTHLOG_HEAP_SNAPSHOT=1 writes ONE
|
||
# heap snapshot the first time the heap reaches 85 % of its limit, into
|
||
# HEALTHLOG_DIAG_DIR (default /tmp inside the container). A snapshot holds
|
||
# decrypted health data and keys: turn it on only to chase a crash, then
|
||
# delete the file. See docs/ops/heap-diagnostics.md.
|
||
HEALTHLOG_HEAP_SNAPSHOT: "${HEALTHLOG_HEAP_SNAPSHOT:-}"
|
||
HEALTHLOG_DIAG_DIR: "${HEALTHLOG_DIAG_DIR:-}"
|
||
# Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the base
|
||
# URL or the full push URL to turn it on; a Loki on your own network works.
|
||
# The username/password pair is only needed when your Loki is behind basic
|
||
# auth. Push failures show up as a rate-limited line on stderr. Leave all
|
||
# three unset and logs stay on stdout for the Docker log driver to rotate.
|
||
LOKI_ENDPOINT: "${LOKI_ENDPOINT:-}"
|
||
LOKI_USERNAME: "${LOKI_USERNAME:-}"
|
||
LOKI_PASSWORD: "${LOKI_PASSWORD:-}"
|
||
# Server-side prefetch for the dashboard, insights, coach and checkups
|
||
# pages. On by default; set "false" to fall back to pure client fetching,
|
||
# which is the escape hatch when a prefetch is misbehaving on your host.
|
||
DASHBOARD_SSR_PREFETCH: "${DASHBOARD_SSR_PREFETCH:-}"
|
||
|
||
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. cap_drop removes
|
||
# every Linux capability on top of that — the process doesn't need any
|
||
# of them either. read_only:true is deliberately NOT set: the app
|
||
# writes to a Next.js runtime cache, and turning the filesystem
|
||
# read-only needs testing against that first, it's a not-yet, not an
|
||
# oversight.
|
||
security_opt:
|
||
- no-new-privileges:true
|
||
cap_drop:
|
||
- ALL
|
||
ports:
|
||
- "3000:3000"
|
||
environment:
|
||
<<: *healthlog_env
|
||
# The database dumps (DB_BACKUP_DIR). Without this mount the app still
|
||
# copies its nightly dump off-host when the bucket is set, but keeps none
|
||
# here and takes none before a migration; the boot log says so.
|
||
volumes:
|
||
- backups:/var/lib/healthlog/backups
|
||
# 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 uncomment below — the container path here
|
||
# matches GEOLITE2_DIR's default (/opt/geolite2), so this works as-is
|
||
# once uncommented. Mounting at a different container path also works,
|
||
# just also set GEOLITE2_DIR in .env to match it.
|
||
# Steps: docs/self-hosting/geolite2.md. The line goes under `volumes:`
|
||
# above, beside the backups line:
|
||
#
|
||
# - ./geolite2:/opt/geolite2:ro
|
||
depends_on:
|
||
db:
|
||
condition: service_healthy
|
||
db-upgrade:
|
||
condition: service_completed_successfully
|
||
restart: unless-stopped
|
||
# Bounded log rotation: the default json-file driver keeps every line
|
||
# forever, which is exactly what fills the disk on the small NAS /
|
||
# homelab hosts this file targets. 10m x 3 caps it at 30MB.
|
||
logging:
|
||
driver: json-file
|
||
options:
|
||
max-size: "10m"
|
||
max-file: "3"
|
||
# Memory ceiling for the app. HEALTHLOG_PROCESS_TYPE=all bundles the
|
||
# AI/rollup/backup worker into this same process, and unbounded that
|
||
# workload can grow large enough to starve Postgres on a shared host.
|
||
# Node sizes its heap from this limit: 1g gives a 524 MB heap, which a
|
||
# long-used account fills in normal operation, so garbage collection
|
||
# stalls requests for seconds. 1536m gives 792 MB and is the supported
|
||
# floor (host: 3 GB RAM, 4 GB recommended). Below it the boot log, the
|
||
# support report and Admin > System status say so. On a busy instance,
|
||
# raise it with APP_MEMORY_LIMIT in .env; do not lower it.
|
||
mem_limit: "${APP_MEMORY_LIMIT:-1536m}"
|
||
# On `docker stop` (a redeploy) the app stops taking requests, lets the
|
||
# requests in flight finish and gives running jobs up to 30 s to end
|
||
# before it exits. Docker's default of 10 s cut that short and left
|
||
# jobs marked active until their expiry, holding exclusive queues.
|
||
stop_grace_period: 45s
|
||
healthcheck:
|
||
test:
|
||
[
|
||
"CMD-SHELL",
|
||
"wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1",
|
||
]
|
||
interval: 30s
|
||
timeout: 5s
|
||
# Five misses in a row, two and a half minutes, before "unhealthy". The
|
||
# probe gives the database two seconds to answer SELECT 1 and says 503
|
||
# past that, so a boot storm that saturates the pool for a minute after
|
||
# the start period (backfills, a restore, another tenant's import) costs
|
||
# a probe or two, not the container's health and the proxy's routing.
|
||
# wget's own 4 s timeout ends the request inside Docker's 5 s, so a
|
||
# stalled connection reads as one clean miss rather than a killed probe.
|
||
# A database that stays away for 2.5 minutes is a real outage and shows.
|
||
retries: 5
|
||
# The entrypoint applies migrations before the server listens. A
|
||
# migration that waits out a held lock retries for up to about eight
|
||
# minutes (docs/ops/deploy.md), and failed probes in that time must not
|
||
# read as a dead container. The first probe that answers still ends
|
||
# the start period, so a normal boot is healthy within 30 seconds of
|
||
# listening. After it listens, the pg-boss worker starts in the
|
||
# background and /api/health answers 503 with reason "starting" until it
|
||
# is up (10–60 s under load, reported as "starting" for at most five
|
||
# minutes); inside the start period those probes do not count, so a slow
|
||
# worker start cannot mark the container unhealthy.
|
||
start_period: 10m
|
||
|
||
# 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
|
||
# security_opt:
|
||
# - no-new-privileges:true
|
||
# cap_drop:
|
||
# - ALL
|
||
# environment:
|
||
# <<: *healthlog_env
|
||
# HEALTHLOG_PROCESS_TYPE: "worker"
|
||
# # The worker takes the nightly database dump, so it needs the volume.
|
||
# volumes:
|
||
# - backups:/var/lib/healthlog/backups
|
||
# depends_on:
|
||
# db:
|
||
# condition: service_healthy
|
||
# db-upgrade:
|
||
# condition: service_completed_successfully
|
||
# restart: unless-stopped
|
||
# logging:
|
||
# driver: json-file
|
||
# options:
|
||
# max-size: "10m"
|
||
# max-file: "3"
|
||
# mem_limit: "${WORKER_MEMORY_LIMIT:-1536m}"
|
||
# stop_grace_period: 45s
|
||
|
||
db:
|
||
# Pinned to the concrete minor instead of the floating `18-alpine`
|
||
# alias so a database update is always a deliberate, reviewable
|
||
# change. Minor releases share the on-disk format, so bumping this pin
|
||
# is safe without a dump / restore; a major version is not, which is
|
||
# what `db-upgrade` below and docs/ops/postgres-major-upgrade.md are for.
|
||
#
|
||
# The pin only works as long as something watches it: PostgreSQL
|
||
# publishes minor releases with security and data-corruption fixes and
|
||
# recommends running the current one, and this line sat two minors
|
||
# behind for five weeks because the Dockerfile was the only thing
|
||
# Dependabot looked at. The `docker-compose` ecosystem in
|
||
# `.github/dependabot.yml` now raises a PR for each new minor;
|
||
# `src/__tests__/database-pin-is-watched.test.ts` keeps both halves
|
||
# (concrete pin, active watch) from being dropped.
|
||
image: postgres:18.6-alpine
|
||
container_name: healthlog-db
|
||
# Loads pg_stat_statements so slow queries can be found from measurements
|
||
# instead of guesses. Loading the library only collects statistics; the
|
||
# view appears after a one-time `CREATE EXTENSION pg_stat_statements;`
|
||
# (docs/ops/query-statistics.md). Changing this line restarts the
|
||
# database once on the next `compose up`.
|
||
command:
|
||
- postgres
|
||
- -c
|
||
- shared_preload_libraries=pg_stat_statements
|
||
- -c
|
||
- pg_stat_statements.track=top
|
||
# Docker gives a container 64 MB of /dev/shm by default. Postgres keeps
|
||
# the shared memory of parallel queries and parallel maintenance there,
|
||
# and a larger plan fails with "could not resize shared memory segment".
|
||
shm_size: 256mb
|
||
# cap_drop: [ALL] is deliberately NOT applied here (unlike `app`): the
|
||
# official Postgres entrypoint starts as root, chowns the data
|
||
# directory, and drops privileges to the `postgres` user itself, which
|
||
# needs CAP_CHOWN/CAP_SETUID/CAP_SETGID to do. Dropping all capabilities
|
||
# makes that startup fail outright (verified locally — the container
|
||
# exits with "failed switching to 'postgres': operation not permitted").
|
||
# no-new-privileges still blocks privilege escalation beyond that.
|
||
security_opt:
|
||
- no-new-privileges:true
|
||
environment:
|
||
POSTGRES_USER: healthlog
|
||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||
POSTGRES_DB: healthlog
|
||
# From 18 on, the image keeps its data under /var/lib/postgresql/18/docker
|
||
# and expects the volume one level up, so the next major can sit beside
|
||
# this one in the same volume. Mounting it at .../data, as 16 had it,
|
||
# makes the container refuse to start.
|
||
volumes:
|
||
- postgres-data:/var/lib/postgresql
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U healthlog"]
|
||
interval: 5s
|
||
timeout: 5s
|
||
retries: 5
|
||
restart: unless-stopped
|
||
# No `mem_limit` here, deliberately, and it is not an oversight. Bounding
|
||
# Postgres is the more dangerous half of the pair: an app container the
|
||
# kernel kills restarts clean, a database killed mid-write does not. With
|
||
# only `app` bounded the app is what gives way first under host pressure,
|
||
# which is the failure order we want. Raise APP_MEMORY_LIMIT before
|
||
# reaching for a limit here.
|
||
# Bounded log rotation, same reasoning as `app`: default json-file grows
|
||
# without bound and fills the disk on a small NAS/homelab host.
|
||
logging:
|
||
driver: json-file
|
||
options:
|
||
max-size: "10m"
|
||
max-file: "3"
|
||
|
||
# Moves a Postgres 16 database from v1.45 or earlier into Postgres 18, once,
|
||
# on the first start after the update. A major version cannot read the data
|
||
# directory of an older one, so changing the image line alone would leave
|
||
# the database refusing to start.
|
||
#
|
||
# On every start it decides in a second and exits:
|
||
# - no `pgdata` data (a new install): nothing to do;
|
||
# - Postgres 18 already holds the database: nothing to do;
|
||
# - otherwise it starts Postgres 16 inside this container on the old
|
||
# volume, streams a dump into the new database in one transaction,
|
||
# compares the row count of every table, gathers planner statistics and
|
||
# marks the old volume as moved. `app` starts only after it succeeded.
|
||
# A move cut short (a restart, a full disk) leaves a "moving" marker, and
|
||
# the next start empties the new database and runs it again from scratch.
|
||
#
|
||
# The old volume is read, never changed, apart from that marker file, so
|
||
# going back is the previous image and volume lines. If anything fails, the
|
||
# new database stays empty, `app` does not start, and the log says why.
|
||
# It costs one small image pull; a later release removes the service.
|
||
db-upgrade:
|
||
image: postgres:16.15-alpine
|
||
container_name: healthlog-db-upgrade
|
||
# Runs once per `compose up` and exits. "no" also keeps hosts such as
|
||
# Coolify from reporting the exited container as a failed service.
|
||
restart: "no"
|
||
security_opt:
|
||
- no-new-privileges:true
|
||
environment:
|
||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||
volumes:
|
||
- pgdata:/var/lib/postgresql/data
|
||
# Read-only, only to check there is room for the copy before starting.
|
||
- postgres-data:/var/lib/postgresql/next:ro
|
||
depends_on:
|
||
db:
|
||
condition: service_healthy
|
||
logging:
|
||
driver: json-file
|
||
options:
|
||
max-size: "10m"
|
||
max-file: "3"
|
||
# `$$` is how compose spells a literal `$`.
|
||
entrypoint: ["/bin/sh", "-c"]
|
||
command:
|
||
- |
|
||
set -eu
|
||
set -o pipefail
|
||
log() { echo "db-upgrade: $$*"; }
|
||
OLD=/var/lib/postgresql/data
|
||
MARK="$$OLD/HEALTHLOG_MOVED_TO_POSTGRES_18"
|
||
MOVING="$$OLD/HEALTHLOG_MOVING_TO_POSTGRES_18"
|
||
export PGPASSWORD="$$POSTGRES_PASSWORD"
|
||
new() { psql -h db -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
|
||
old() { psql -h /tmp -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
|
||
empty_new() {
|
||
for S in $$(new "SELECT quote_ident(nspname) FROM pg_namespace WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'"); do
|
||
new "DROP SCHEMA $$S CASCADE"
|
||
done
|
||
new "CREATE SCHEMA public"
|
||
}
|
||
if [ ! -s "$$OLD/PG_VERSION" ]; then
|
||
log "no Postgres 16 database to move, nothing to do"
|
||
exit 0
|
||
fi
|
||
USER_TABLES="FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')"
|
||
TABLES=$$(new "SELECT count(*) $$USER_TABLES")
|
||
if [ -e "$$MARK" ]; then
|
||
if [ "$$TABLES" != 0 ]; then
|
||
log "the Postgres 16 database was moved on $$(cat "$$MARK"), nothing to do"
|
||
exit 0
|
||
fi
|
||
log "the Postgres 16 database was moved on $$(cat "$$MARK"), but the Postgres 18 database is empty now."
|
||
log "Not importing the old state again by itself. See docs/ops/postgres-major-upgrade.md, \"Starting over\"."
|
||
exit 1
|
||
fi
|
||
if [ -e "$$MOVING" ]; then
|
||
log "an earlier move did not finish; emptying the Postgres 18 database and starting it again"
|
||
empty_new
|
||
elif [ "$$TABLES" != 0 ]; then
|
||
log "the Postgres 18 database already holds data, leaving both as they are"
|
||
exit 0
|
||
fi
|
||
VERSION=$$(cat "$$OLD/PG_VERSION")
|
||
if [ "$$VERSION" != 16 ]; then
|
||
log "the old data directory is Postgres $$VERSION, this step moves Postgres 16 only."
|
||
log "See docs/ops/postgres-major-upgrade.md to move it by hand."
|
||
exit 1
|
||
fi
|
||
NEED=$$(du -sk "$$OLD" | cut -f1)
|
||
FREE=$$(df -Pk /var/lib/postgresql/next | awk 'NR == 2 { print $$4 }')
|
||
if [ "$$FREE" -le "$$NEED" ]; then
|
||
log "the move needs about $$((NEED / 1024)) MiB free for the new database, the disk has $$((FREE / 1024)) MiB."
|
||
log "Nothing was changed. Free some space and start the stack again."
|
||
exit 1
|
||
fi
|
||
trap 'CODE=$$?; [ "$$CODE" = 0 ] || log "the move stopped (exit $$CODE). The old volume is untouched; the next start begins it again."' EXIT
|
||
date -u +%Y-%m-%dT%H:%M:%SZ > "$$MOVING"
|
||
log "moving the Postgres 16 database ($$((NEED / 1024)) MiB on disk) into Postgres 18"
|
||
su-exec postgres postgres -D "$$OLD" -c listen_addresses= -c unix_socket_directories=/tmp &
|
||
PID=$$!
|
||
i=0
|
||
until pg_isready -h /tmp -U healthlog -q; do
|
||
kill -0 "$$PID" 2>/dev/null || { log "the Postgres 16 server did not start, see the lines above"; exit 1; }
|
||
i=$$((i + 1))
|
||
[ "$$i" -lt 600 ] || { log "the Postgres 16 server did not get ready in ten minutes"; exit 1; }
|
||
sleep 1
|
||
done
|
||
pg_dump -h /tmp -U healthlog -Fc healthlog |
|
||
pg_restore -h db -U healthlog -d healthlog --no-owner --single-transaction --exit-on-error
|
||
COUNTED=0
|
||
for T in $$(old "SELECT quote_ident(schemaname) || '.' || quote_ident(tablename) $$USER_TABLES ORDER BY 1"); do
|
||
A=$$(old "SELECT count(*) FROM $$T")
|
||
B=$$(new "SELECT count(*) FROM $$T")
|
||
if [ "$$A" != "$$B" ]; then
|
||
log "table $$T has $$A rows in Postgres 16 but $$B in Postgres 18. Emptying the new database again."
|
||
log "Please report this with the lines above."
|
||
empty_new
|
||
exit 1
|
||
fi
|
||
COUNTED=$$((COUNTED + 1))
|
||
done
|
||
log "all $$COUNTED tables arrived with the same row counts"
|
||
vacuumdb -h db -U healthlog -d healthlog --analyze-in-stages --quiet
|
||
kill -INT "$$PID"
|
||
wait "$$PID" || true
|
||
mv "$$MOVING" "$$MARK"
|
||
log "done. The old volume stays as it was, as a way back; see docs/ops/postgres-major-upgrade.md"
|
||
|
||
# Restores a database dump into an EMPTY database (docs/ops/disaster-recovery.md).
|
||
# Never started by `docker compose up`; run it by name:
|
||
#
|
||
# docker compose run --rm db-restore list
|
||
# docker compose run --rm db-restore latest
|
||
# docker compose run --rm db-restore healthlog-<time>-daily.dump
|
||
# docker compose run --rm db-restore latest --from-bucket
|
||
#
|
||
# It checks the dump against its manifest and the keys in .env against the
|
||
# keys the dump was written with, refuses a database that already holds
|
||
# tables, restores in one transaction and compares every table's row count
|
||
# with the manifest. Start the app only after it says "Restored".
|
||
# `--from-bucket` downloads the dump and its manifest from the off-host bucket
|
||
# (`database/`, the BACKUP_S3_* variables) and opens them with
|
||
# BACKUP_ENCRYPTION_KEY: the restore after the host and its local dumps are
|
||
# gone. Files you downloaded yourself restore from a directory you mount:
|
||
# `docker compose run --rm -v ./restore:/restore:ro db-restore
|
||
# /restore/healthlog-<time>-daily.dump.enc`.
|
||
db-restore:
|
||
image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
|
||
profiles: ["restore"]
|
||
security_opt:
|
||
- no-new-privileges:true
|
||
cap_drop:
|
||
- ALL
|
||
environment:
|
||
<<: *healthlog_env
|
||
volumes:
|
||
- backups:/var/lib/healthlog/backups
|
||
depends_on:
|
||
db:
|
||
condition: service_healthy
|
||
entrypoint: ["node", "scripts/db-restore.mjs"]
|
||
command: ["list"]
|
||
|
||
volumes:
|
||
postgres-data:
|
||
pgdata:
|
||
backups:
|