# Docker Swarm stack for prod — the autoscaling successor to docker-compose.yml # on that host (beta and LAN dev stay on plain compose; deploy.sh routes by the # /etc/thermograph/deploy-mode marker). Two-image world: web/worker run the # backend image, frontend its own — per-service tags, same contract as compose. # # Deployed by deploy/stack/deploy-stack.sh, which: # - sources /etc/thermograph.env (SOPS-rendered) so ${VARS} here interpolate, # - installs a uid-10001-readable copy at /etc/thermograph/stack.env that # deploy/stack/env-entrypoint.sh sources inside each app task (set-if-unset, # so `environment:` blocks below always win) — this replaces compose's # env_file:, which `docker stack deploy` does not support, # - runs migrations as a ONE-SHOT task before rolling (RUN_MIGRATIONS=0 in # every replica — N replicas must never race Alembic), # - manages the loopback LB bridge (see lb/README note below): Swarm's mesh # can only publish on 0.0.0.0 (would expose the plaintext app un-fronted), # so nothing here has `ports:`. A plain container on this attachable # overlay binds 127.0.0.1:8137/8080 for the host Caddy and proxies to the # service VIPs — Swarm's VIP does the actual load balancing across # replicas. # # Scaling model: `web` is stateless (ROLE=web never runs the notifier) and # scales 1..N — the autoscaler service adjusts replicas between # WEB_MIN_REPLICAS/WEB_MAX_REPLICAS on task CPU. `worker` owns the notifier + # scheduler: exactly 1 replica, with the cluster-wide Postgres advisory lock # (THERMOGRAPH_SINGLETON_PG) as belt-and-suspenders. `db` is exactly 1 — a # database does not scale by container count on one host; its levers are # DB_CPUS/DB_MEMORY (and, multi-host later, Patroni replicas per the topology # doc). Everything is pinned to the manager node: all volumes are local to # prod today. That constraint is the ONLY thing to relax when a second app # node joins. # # TIMESCALEDB_IMAGE must be the exact image (digest-pinned) the compose stack # was running — see hazard #7 in the hop-1 runbook: a floating tag can change # the extension minor under an existing volume. deploy-stack.sh resolves it # from the running/last-known container automatically. services: db: image: ${TIMESCALEDB_IMAGE:?required} environment: POSTGRES_USER: thermograph POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required} POSTGRES_DB: thermograph DB_MEMORY: ${DB_MEMORY:-16g} volumes: - pgdata:/var/lib/postgresql - /opt/thermograph/infra/deploy/db/init:/docker-entrypoint-initdb.d:ro networks: - internal healthcheck: test: ["CMD-SHELL", "pg_isready -U thermograph -d thermograph"] interval: 5s timeout: 5s retries: 10 deploy: replicas: 1 placement: constraints: ["node.role == manager"] resources: limits: cpus: "${DB_CPUS:-4}" memory: ${DB_MEMORY:-16g} restart_policy: condition: on-failure web: image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} entrypoint: ["/host/env-entrypoint.sh"] environment: THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph THERMOGRAPH_BASE: / PORT: 8137 THERMOGRAPH_SERVICE_ROLE: backend THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://frontend:8080 WORKERS: ${WEB_WORKERS:-4} THERMOGRAPH_DATA_DIR: /state # web NEVER runs the notifier/scheduler, even if it would win election — # that's the worker service's job. This is what makes web replicas safe. THERMOGRAPH_ROLE: web RUN_MIGRATIONS: "0" # Overlay tasks reach the HOST's Postfix via the docker_gwbridge gateway, # not the compose bridge's 172.19.0.1 (an overlay has no host gateway). # provision-mail.sh's DOCKER_MAIL_GATEWAY/SUBNET cover this listener. THERMOGRAPH_SMTP_HOST: ${STACK_SMTP_HOST:-172.18.0.1} volumes: - appdata:/state - applogs:/app/logs - /opt/thermograph/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro - /etc/thermograph/stack.env:/host/thermograph.env:ro networks: - internal deploy: replicas: ${WEB_MIN_REPLICAS:-1} placement: constraints: ["node.role == manager"] resources: limits: cpus: "${WEB_CPUS:-4}" restart_policy: condition: on-failure update_config: # New task must pass the image's own HEALTHCHECK before the old one # stops — zero-downtime single-service rolls. order: start-first failure_action: rollback worker: image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} entrypoint: ["/host/env-entrypoint.sh"] environment: THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph THERMOGRAPH_BASE: / PORT: 8137 THERMOGRAPH_SERVICE_ROLE: backend THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://frontend:8080 WORKERS: "1" THERMOGRAPH_DATA_DIR: /state THERMOGRAPH_ROLE: worker # Cluster-wide Postgres advisory lock, not the host flock: correct at # replicas=1 today and stays correct if a second worker ever appears. THERMOGRAPH_SINGLETON_PG: "1" RUN_MIGRATIONS: "0" THERMOGRAPH_SMTP_HOST: ${STACK_SMTP_HOST:-172.18.0.1} volumes: - appdata:/state - applogs:/app/logs - /opt/thermograph/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro - /etc/thermograph/stack.env:/host/thermograph.env:ro networks: - internal deploy: replicas: 1 placement: constraints: ["node.role == manager"] resources: limits: cpus: "${WORKER_CPUS:-2}" restart_policy: condition: on-failure frontend: image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-emi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:?required} entrypoint: ["/host/env-entrypoint.sh"] environment: THERMOGRAPH_BASE: / PORT: 8080 THERMOGRAPH_SERVICE_ROLE: frontend THERMOGRAPH_API_BASE_INTERNAL: http://web:8137 volumes: - /opt/thermograph/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro - /etc/thermograph/stack.env:/host/thermograph.env:ro networks: - internal deploy: replicas: 1 placement: constraints: ["node.role == manager"] resources: limits: cpus: "${FRONTEND_CPUS:-2}" restart_policy: condition: on-failure update_config: order: start-first failure_action: rollback # Scales `web` between WEB_MIN_REPLICAS and WEB_MAX_REPLICAS on sustained # task CPU (docker stats on this node — every task is pinned here today). # Deliberately a dumb shell loop with hysteresis + cooldown, not an # autoscaling framework: Swarm has no native autoscaler and this workload # doesn't justify one (topology doc §6 — declarative scaling as a feature). autoscaler: image: docker:27-cli entrypoint: ["/bin/sh", "/host/autoscale.sh"] environment: STACK_NAME: ${STACK_NAME:-thermograph} MIN_REPLICAS: ${WEB_MIN_REPLICAS:-1} MAX_REPLICAS: ${WEB_MAX_REPLICAS:-3} # Thresholds are avg docker-stats CPU% PER TASK (host-core-relative: 100 # = one full core). Up fast, down slow. SCALE_UP_CPU: ${SCALE_UP_CPU:-220} SCALE_DOWN_CPU: ${SCALE_DOWN_CPU:-60} POLL_SECONDS: ${POLL_SECONDS:-15} UP_SAMPLES: ${UP_SAMPLES:-3} DOWN_SAMPLES: ${DOWN_SAMPLES:-20} COOLDOWN_SECONDS: ${COOLDOWN_SECONDS:-180} volumes: - /var/run/docker.sock:/var/run/docker.sock - /opt/thermograph/infra/deploy/stack/autoscale.sh:/host/autoscale.sh:ro networks: - internal deploy: replicas: 1 placement: constraints: ["node.role == manager"] resources: limits: cpus: "0.2" memory: 64m restart_policy: condition: any networks: internal: driver: overlay # Attachable so the loopback LB bridge (a PLAIN container — only plain # containers can bind 127.0.0.1; Swarm port configs cannot) can join and # reach the service VIPs. attachable: true volumes: # External and explicitly named: the real stack REUSES the volumes the # compose stack created (same data, zero migration). deploy-stack.sh's test # mode points these at throwaway names instead. pgdata: external: true name: ${PGDATA_VOLUME:-thermograph_pgdata} appdata: external: true name: ${APPDATA_VOLUME:-thermograph_appdata} applogs: external: true name: ${APPLOGS_VOLUME:-thermograph_applogs}