Files
HealthLog/docker-compose.yml
T
Marc-André Bombeck d7bf0df10b chore(ci): pin the supply chain and surface build provenance
Every workflow action is pinned to a commit SHA, dependabot auto-merge
is limited to patch and minor updates behind the now-required status
checks on main, and the audit, Trivy and ESLint gates block instead of
warning. The published image finally carries its build SHA and build
timestamp so the version endpoint can prove which commit is running.
The API-token HMAC key fails closed under 32 characters, compose pins
the database minor and denies privilege escalation, and the changelog
drops working-note references in favour of neutral wording.
2026-06-10 15:05:05 +02:00

158 lines
7.5 KiB
YAML

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. `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. The small bandwidth cost on every
# `compose up` is the price of digest-correctness.
image: ghcr.io/mbombeck/healthlog:latest
pull_policy: always
container_name: healthlog-app
# The app runs as the unprivileged `nextjs` user and never needs a
# setuid/setgid escalation; blocking privilege gain caps what a
# compromised process inside the container can do.
security_opt:
- no-new-privileges:true
ports:
- "3000:3000"
environment:
# connection_limit is set explicitly: Prisma's own default is
# cpu_count*2+1, which starves the combined web+worker process
# (HEALTHLOG_PROCESS_TYPE=all) under background AI/rollup load. Default
# 20 suits a small host; raise DB_CONNECTION_LIMIT on a busy one (keep it
# below the database's max_connections, leaving headroom for pg-boss,
# migrations, and backups). pool_timeout is the seconds a query waits for
# a free connection before erroring.
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}"
NODE_ENV: production
ENCRYPTION_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}"
# v1.5.1 session-cookie Secure-flag opt-out. Forwarded explicitly so
# an operator setting SESSION_COOKIE_SECURE=false in .env actually
# reaches the Node process (the compose `environment:` block is a
# whitelist — vars not listed here don't propagate from .env into
# the container, even though docker-compose reads .env for
# `${VAR}` substitution at compose-up time).
SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}"
# all = bundle web + worker in this container (default, single-host)
# web = serve HTTP only; pair with a separate app-worker service
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}"
# Per-export ceiling on MedicationAdministration rows in the FHIR
# health-record Bundle. Bounded 1..50000, default 5000. Listed here so
# an operator override in .env actually reaches the container (the
# `environment:` block is a whitelist).
FHIR_MAX_MEDICATION_ADMINISTRATIONS: "${FHIR_MAX_MEDICATION_ADMINISTRATIONS:-}"
# WHOOP integration (optional). The app client id/secret are per-user
# BYO-keys stored encrypted in the DB, so there is no
# WHOOP_CLIENT_ID/SECRET env. WHOOP_WEBHOOK_SECRET is the instance-level
# path-segment + HMAC-verification secret for the webhook;
# WHOOP_REDIRECT_URI overrides the derived
# `${NEXT_PUBLIC_APP_URL}/api/whoop/callback` when the public URL
# differs. Listed here so an operator override in .env reaches the
# container (the `environment:` block is a whitelist).
WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}"
WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}"
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. Listed here so operator values in
# .env reach the container (the `environment:` block is a whitelist).
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
# 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:-}"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/version || exit 1",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
# Optional dedicated worker container — uncomment + set
# HEALTHLOG_PROCESS_TYPE=web on `app` to split the deployment.
# See docs/self-hosting/scaling.md for the full guide.
#
# app-worker:
# image: ghcr.io/mbombeck/healthlog:latest
# build:
# context: .
# dockerfile: Dockerfile
# container_name: healthlog-worker
# environment:
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
# NODE_ENV: production
# ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
# ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
# ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
# API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
# APP_URL: "${APP_URL:-http://localhost:3000}"
# HEALTHLOG_PROCESS_TYPE: "worker"
# BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
# BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
# BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
# BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
# BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
# BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
# depends_on:
# db:
# condition: service_healthy
# restart: unless-stopped
db:
# Pinned to the concrete minor instead of the floating `16-alpine`
# alias so a database update is always a deliberate, reviewable
# change (at pin time `16-alpine` resolved to this same digest, so
# existing deployments see no image change). Minor releases share
# the on-disk format — bumping this pin is safe without a dump /
# restore; a major-version bump (17+) is not.
image: postgres:16.14-alpine
container_name: healthlog-db
security_opt:
- no-new-privileges:true
environment:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
POSTGRES_DB: healthlog
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U healthlog"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata: