mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
892 lines
49 KiB
YAML
892 lines
49 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:-}"
|
||
# The app connects as `healthlog_app`, a role the entrypoint creates that
|
||
# reads and writes rows but cannot change the schema; the migrations keep
|
||
# the owner above. off keeps the app on the owner (for an operator who
|
||
# manages database roles). See docs/self-hosting/database-role.md.
|
||
DB_APP_ROLE: "${DB_APP_ROLE:-}"
|
||
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).
|
||
# TRUSTED_PROXY_CIDRS names the addresses the reverse proxy connects from
|
||
# (comma-separated CIDRs). Set, only those connections may report a client
|
||
# address; unset, any private address may, which behind Docker Desktop,
|
||
# OrbStack or docker-proxy includes every caller of the published port.
|
||
# Same host: 127.0.0.1/32,::1/128. Docker network: the proxy's subnet.
|
||
# See docs/self-hosting/reverse-proxy.md.
|
||
TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"
|
||
TRUSTED_PROXY_CIDRS: "${TRUSTED_PROXY_CIDRS:-}"
|
||
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 also requires a
|
||
# fresh X-Deploy-Webhook-Timestamp on a secret-header request;
|
||
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true accepts signed requests only, the
|
||
# one setting that stops a replay. 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:-}"
|
||
DEPLOY_WEBHOOK_REQUIRE_SIGNATURE: "${DEPLOY_WEBHOOK_REQUIRE_SIGNATURE:-}"
|
||
# 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. Unset, each sign-in picks from the
|
||
# models its account is offered, with a built-in order as the fallback.
|
||
# Set CODEX_MODEL to pin one slug ahead of that, or
|
||
# CODEX_MODEL_FALLBACK_CHAIN (comma-separated) to replace the order
|
||
# outright; the account's list is then not read.
|
||
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;
|
||
# - the database service runs Postgres 16 (after going back): nothing to
|
||
# do, the live 16 volume is left alone;
|
||
# - 16 was used again after an earlier move: refuses, so the app does not
|
||
# start on the move-day copy;
|
||
# - 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
|
||
# The database service must be the new major. After going back to 16 the
|
||
# service runs on this very volume: touching it then would wipe the live data.
|
||
i=0
|
||
until NEWVER=$$(new "SHOW server_version_num" 2>/dev/null); do
|
||
i=$$((i + 1))
|
||
[ "$$i" -lt 60 ] || { log "the database service did not answer for a minute"; exit 1; }
|
||
sleep 1
|
||
done
|
||
if [ "$$NEWVER" -lt 180000 ]; then
|
||
log "the database service runs Postgres $$((NEWVER / 10000)), not 18: nothing to move, the old volume is left alone"
|
||
exit 0
|
||
fi
|
||
checkpoint() { pg_controldata "$$OLD" | awk -F': *' '/^Latest checkpoint location/ { print $$2 }'; }
|
||
USER_TABLES="FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')"
|
||
TABLES=$$(new "SELECT count(*) $$USER_TABLES")
|
||
if [ -e "$$MARK" ]; then
|
||
MOVED_ON=$$(sed -n 1p "$$MARK")
|
||
MOVED_CHECKPOINT=$$(sed -n 2p "$$MARK")
|
||
if [ -n "$$MOVED_CHECKPOINT" ] && [ "$$(checkpoint)" != "$$MOVED_CHECKPOINT" ]; then
|
||
log "the Postgres 16 database changed after it was moved on $$MOVED_ON: it was used again after going back to 16."
|
||
log "Postgres 18 still holds the state of that day, so it is not started on. To move the current 16 database,"
|
||
log "see docs/ops/postgres-major-upgrade.md, \"Updating again after going back\"."
|
||
exit 1
|
||
fi
|
||
if [ "$$TABLES" != 0 ]; then
|
||
log "the Postgres 16 database was moved on $$MOVED_ON, nothing to do"
|
||
exit 0
|
||
fi
|
||
log "the Postgres 16 database was moved on $$MOVED_ON, 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 }')
|
||
# Room for the copy, and for the dump the app takes before its first migration.
|
||
if [ "$$FREE" -le $$((NEED * 3 / 2)) ]; then
|
||
log "the move needs about $$((NEED * 3 / 2 / 1024)) MiB free for the new database and its first dump, 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
|
||
# The marker keeps the old database's last checkpoint: a later start can tell
|
||
# whether 16 was used again after the move.
|
||
{ date -u +%Y-%m-%dT%H:%M:%SZ; checkpoint; } > "$$MOVING"
|
||
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:
|