# DOMINUS — Production Docker Compose profile. # Extends the base compose.yml for production deployments. # # Usage: # docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d # # Override with your own .env file: # docker compose --env-file .env -f docker-compose.yml -f docker-compose.prod.yml up -d # # What this profile does: # - Uses GHCR images (published by the Deploy workflow) instead of builds # - Applies resource limits and healthchecks to every service # - Owns the Cloud data stores: postgres + redis are defined here (the # base compose intentionally has no external stores — community runs # on SQLite), so the documented command starts the full stack # - Adds the monitoring stack: prometheus, alertmanager, grafana # # Image pinning (ADR-0046): # The Deploy workflow publishes `master`, `sha-` and `vX.Y.Z` # tags (plus -worker/-scheduler variants). DOMINUS_IMAGE_TAG is REQUIRED: # the default `master` was removed so a rollout that forgets the pin # fails fast instead of silently deploying a drifting tag. Pin it to a # release tag or a sha tag for immutable rollouts: # DOMINUS_IMAGE_TAG=v0.10.1 # DOMINUS_IMAGE_TAG=sha-6f4b3c2a1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a # # Backups: # A dedicated `backups` volume keeps backups off the disk that holds # dominus.db (same host, but isolated from DB write amplification and # from accidental wipe of ./data). For true off-disk durability, mount # the Hetzner Volume (or any external drive) instead — see # docs/deployment/README.md. Never place dominus.db itself on a network # filesystem — SQLite WAL mode is unsafe over NFS/SMB. # # Monitoring: # Prometheus scrapes http://api:3000/api/v1/metrics/prometheus (no auth # by default, reachable only on the Docker network — do not expose /metrics # publicly through the reverse proxy). If the API sets METRICS_TOKEN, the # Prometheus scrape must send the same token too (authorization block in # deploy/prometheus/prometheus.yml) or it will be rejected with 401 while # the API is protected. Alertmanager forwards alerts to the webhook # URL set in deploy/prometheus/alertmanager.yml (set it before deploying; # point it at the same endpoint as NOTIFIER_WEBHOOK_URL to keep a single # alert inbox). Grafana binds 127.0.0.1:3001 by default — reach it via an # SSH tunnel or set GRAFANA_BIND to a real address behind a proxy. # # Fail-fast defaults (no silent drift): # - GRAFANA_ADMIN_PASSWORD is required: the image default admin/admin is # never acceptable on a production host, so the overlay refuses to # start without an explicit credential. # - The API port binds to 127.0.0.1 (base compose): expose it through a # reverse proxy, not the host network. # - Postgres runs with ssl=on and hostssl-preferred pg_hba rows # (deploy/postgres/init-ssl.sh); set sslmode=require in DATABASE_URL # to enforce TLS on the app connection. services: api: image: ghcr.io/alessiobrillo/dominus:${DOMINUS_IMAGE_TAG:?DOMINUS_IMAGE_TAG is required (ADR-0046) — set it to vX.Y.Z or sha-} restart: always deploy: resources: limits: cpus: '1.0' memory: 512M reservations: cpus: '0.25' memory: 128M healthcheck: test: ["CMD", "node", "dist/app/healthcheck-cli.js"] interval: 30s timeout: 5s retries: 3 start_period: 15s logging: driver: "json-file" options: max-size: "10m" max-file: "3" compress: "true" worker: image: ghcr.io/alessiobrillo/dominus:${DOMINUS_IMAGE_TAG:?DOMINUS_IMAGE_TAG is required (ADR-0046) — set it to vX.Y.Z or sha-}-worker restart: always depends_on: redis: condition: service_healthy deploy: resources: limits: cpus: '0.5' memory: 256M reservations: cpus: '0.1' memory: 64M volumes: - backups:/backups environment: - BACKUP_DIR=/backups # The worker entrypoint exposes a loopback health server on 9090 # (/health -> 200 only while the worker is polling/processing). healthcheck: test: ["CMD", "node", "-e", "require('node:http').get('http://127.0.0.1:9090/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"] interval: 30s timeout: 5s retries: 3 start_period: 15s logging: driver: "json-file" options: max-size: "10m" max-file: "3" compress: "true" scheduler: image: ghcr.io/alessiobrillo/dominus:${DOMINUS_IMAGE_TAG:?DOMINUS_IMAGE_TAG is required (ADR-0046) — set it to vX.Y.Z or sha-}-scheduler restart: always depends_on: redis: condition: service_healthy deploy: resources: limits: cpus: '0.25' memory: 128M reservations: cpus: '0.1' memory: 64M volumes: - backups:/backups environment: - BACKUP_DIR=/backups # The scheduler entrypoint exposes a loopback health server on 9091. healthcheck: test: ["CMD", "node", "-e", "require('node:http').get('http://127.0.0.1:9091/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"] interval: 30s timeout: 5s retries: 3 start_period: 15s logging: driver: "json-file" options: max-size: "10m" max-file: "3" compress: "true" # ── Cloud data stores (defined here, not in the base file: the community # edition runs on SQLite and must never pull these containers) ───────── postgres: image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 restart: always # Point-in-time recovery (ADR-0054): archive every 16MB segment into a # subdir of PGDATA immediately after it is switched (archive_mode=on), # or at least every 5 minutes of activity (archive_timeout). Standing # inside PGDATA avoids volume-ownership pitfalls and keeps the archive # on the same persistent disk as the database; base backups land in # /backups via deploy/postgres/base-backup.sh (host cron). Trade-off: # WAL archive shares the DB disk — off-VPS durability comes from # shipping backups off-host (see docs/deployment/README.md). command: - postgres - -c - wal_level=replica - -c - archive_mode=on - -c - archive_command=mkdir -p /var/lib/postgresql/data/archive && test ! -f /var/lib/postgresql/data/archive/%f && cp %p /var/lib/postgresql/data/archive/%f - -c - archive_timeout=300 # TLS: certs are generated by deploy/postgres/init-ssl.sh on first # initialisation (on the pgdata volume); pg_hba gains hostssl rows # ahead of the image's host rows, so TLS-capable clients (including # the app with sslmode=require in DATABASE_URL) always negotiate TLS # while pre-existing non-SSL clients keep working. - -c - ssl=on - -c - ssl_cert_file=/var/lib/postgresql/ssl/server.crt - -c - ssl_key_file=/var/lib/postgresql/ssl/server.key deploy: resources: limits: cpus: '0.5' memory: 512M reservations: cpus: '0.1' memory: 128M env_file: .env environment: POSTGRES_USER: ${POSTGRES_USER:-dominus} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set} POSTGRES_DB: ${POSTGRES_DB:-dominus} DOMINUS_APP_PASSWORD: ${DOMINUS_APP_PASSWORD:?DOMINUS_APP_PASSWORD must be set} volumes: - pgdata:/var/lib/postgresql/data - ./deploy/postgres/init-ssl.sh:/docker-entrypoint-initdb.d/init-ssl.sh:ro - ./deploy/postgres/init-app-role.sh:/docker-entrypoint-initdb.d/init-app-role.sh:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dominus}"] interval: 10s timeout: 5s retries: 5 logging: driver: "json-file" options: max-size: "10m" max-file: "3" compress: "true" redis: image: redis:7-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2 restart: always command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy volatile-lru deploy: resources: limits: cpus: '0.25' memory: 192M reservations: cpus: '0.05' memory: 32M volumes: - redis-data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 3s retries: 5 logging: driver: "json-file" options: max-size: "10m" max-file: "3" compress: "true" # Redis metrics exporter — scraped by Prometheus so eviction pressure and # memory usage are observable (RedisDown / RedisEvictionsHigh alerts). redis-exporter: image: oliver006/redis_exporter:v1.64.0@sha256:164814f4fb1455eb96a303a7fe4cebe0f6db62ae533c167adc97be6e04e94bc3 restart: always command: - --check-keys - --redis.addr=redis://redis:6379 depends_on: - redis deploy: resources: limits: cpus: '0.1' memory: 64M reservations: cpus: '0.01' memory: 16M logging: driver: "json-file" options: max-size: "10m" max-file: "3" compress: "true" # ── Monitoring stack ────────────────────────────────────────────────── prometheus: image: prom/prometheus:v2.53.1@sha256:f20d3127bf2876f4a1df76246fca576b41ddf1125ed1c546fbd8b16ea55117e6 restart: always command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.path=/prometheus - --storage.tsdb.retention.time=30d - --web.enable-lifecycle deploy: resources: limits: cpus: '0.25' memory: 256M reservations: cpus: '0.05' memory: 64M volumes: - ./deploy/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./deploy/prometheus/rules.yml:/etc/prometheus/rules.yml:ro - prometheus-data:/prometheus depends_on: - api logging: driver: "json-file" options: max-size: "10m" max-file: "3" compress: "true" alertmanager: image: prom/alertmanager:v0.27.0@sha256:e13b6ed5cb929eeaee733479dce55e10eb3bc2e9c4586c705a4e8da41e5eacf5 restart: always command: - --config.file=/etc/alertmanager/alertmanager.yml - --storage.path=/alertmanager deploy: resources: limits: cpus: '0.1' memory: 64M reservations: cpus: '0.01' memory: 16M volumes: - ./deploy/prometheus/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro - alertmanager-data:/alertmanager logging: driver: "json-file" options: max-size: "10m" max-file: "3" compress: "true" grafana: image: grafana/grafana-oss:11.2.0@sha256:408afb9726de5122b00a2576763a8a57a3c86d5b0eff5305bc994ceb3eb96c3f restart: always ports: - "${GRAFANA_BIND:-127.0.0.1}:3001:3000" environment: - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD must be set — no default admin/admin credentials in production} - GF_USERS_ALLOW_SIGN_UP=false - GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-http://localhost:3001} deploy: resources: limits: cpus: '0.25' memory: 128M reservations: cpus: '0.05' memory: 32M volumes: - ./deploy/grafana/provisioning:/etc/grafana/provisioning:ro - grafana-data:/var/lib/grafana logging: driver: "json-file" options: max-size: "10m" max-file: "3" compress: "true" volumes: backups: pgdata: redis-data: prometheus-data: alertmanager-data: grafana-data: