# 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} # History reads ask the lake service (Swarm VIP over 1..2 replicas) # before any third-party source; unset on beta/LAN, where compose has no # lake service and climate.py falls straight through to NASA. THERMOGRAPH_LAKE_URL: http://lake:8141 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} THERMOGRAPH_LAKE_URL: http://lake:8141 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 # The ERA5 lake indexer: SQL-style, partition-pruned reads over the # era5-thermograph bucket with a node-local parquet cache, so a cell's # history is one LAN hop for web instead of an S3 fetch (or a NASA call). # Same backend image; THERMOGRAPH_ROLE=lake makes the entrypoint start # lake_app (no database, no migrations). Its S3 credentials # (THERMOGRAPH_LAKE_S3_ACCESS_KEY/_SECRET_KEY) arrive via the same rendered # /host/thermograph.env as every other secret. Without them it stays # healthy and answers 503/404, and web falls through to NASA — the lake is # an accelerator, never a point of failure. lake: image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} entrypoint: ["/host/env-entrypoint.sh"] environment: THERMOGRAPH_ROLE: lake PORT: 8141 THERMOGRAPH_SERVICE_ROLE: backend WORKERS: "1" THERMOGRAPH_LAKE_CACHE: /state/lake-cache volumes: # Replicas share the cache; lake_app's tmp+rename writes make that safe. - lakecache:/state - /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: ${LAKE_MIN_REPLICAS:-1} placement: constraints: ["node.role == manager"] resources: limits: cpus: "${LAKE_CPUS:-2}" restart_policy: condition: on-failure update_config: order: start-first failure_action: rollback daemon: # SAME image and tag as web/worker on purpose: the daemon binary # (/usr/local/bin/thermograph-daemon, built into the backend image) and the # app share the /internal/* API contract, so one BACKEND_IMAGE_TAG rolls # them together and they can never skew. image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} # Not env-entrypoint.sh: that shim's handoff execs the image's # deploy/entrypoint.sh (Alembic + uvicorn) whenever it exists, which is # exactly what this service must never run — migrations belong to the # one-shot task, and a second migrator racing it is a real hazard. Instead # source the same host-rendered env directly (it is shell-sourceable by # contract — deploy.sh sources it too) and exec the binary. stack.env holds # vault secrets, never service-topology URLs, so it cannot collide with the # `environment:` block below. entrypoint: - /bin/bash - -c - 'set -a; [ -f /host/thermograph.env ] && . /host/thermograph.env; set +a; exec /usr/local/bin/thermograph-daemon' environment: # The service VIP: the daemon's /internal/* calls load-balance across web # replicas like any other client of the app. THERMOGRAPH_API_BASE_INTERNAL: http://web:8137 # Only the env mount — no appdata/applogs: warm-cities/IndexNow work # executes inside the web process (the daemon just fires the internal # endpoints on a timer), so the daemon holds no state and logs to stdout. volumes: - /etc/thermograph/stack.env:/host/thermograph.env:ro # The image HEALTHCHECK curls /healthz on ${PORT} — right for the app, # meaningless for the daemon (it serves nothing). Without this override # Swarm marks the task unhealthy and restarts it forever (seen live on the # first prod stack deploy: gateway up, killed ~100s later, 0/1 loop). The # compose file already disables it; the stack file must too. healthcheck: disable: true networks: - internal deploy: # EXACTLY 1, load-bearing — do not scale this service, ever. Discord # permits ONE gateway connection per bot token; the Python bot enforced # that with a leader election (core/singleton.claim_leader), and this # pinned replica count is what REPLACES that election. A second replica # means two gateway sessions fighting over the token (Discord kicks # both into a reconnect storm) and every timer job firing twice. The # autoscaler cannot touch this — it scales ${STACK_NAME}_web only. replicas: 1 placement: constraints: ["node.role == manager"] resources: limits: cpus: "0.5" memory: 128m restart_policy: condition: on-failure update_config: # stop-first (the default), NOT start-first: a start-first roll would # briefly run two daemons — two gateway sessions on one token, the # exact thing replicas:1 exists to prevent. A few seconds of gateway # downtime per deploy is the correct trade. order: stop-first failure_action: rollback 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 # Second autoscaler instance, watching `lake` (1..2 replicas). The lake is # read-only and cache-heavy, so it saturates on decode CPU well below web's # thresholds — scale up sooner, still down slowly. autoscaler-lake: image: docker:27-cli entrypoint: ["/bin/sh", "/host/autoscale.sh"] environment: STACK_NAME: ${STACK_NAME:-thermograph} TARGET_SERVICE: lake MIN_REPLICAS: ${LAKE_MIN_REPLICAS:-1} MAX_REPLICAS: ${LAKE_MAX_REPLICAS:-2} SCALE_UP_CPU: ${LAKE_SCALE_UP_CPU:-120} SCALE_DOWN_CPU: ${LAKE_SCALE_DOWN_CPU:-30} 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} # Lake parquet cache: node-local and disposable (a cold cache just refills # from the bucket), so NOT external — the stack owns its lifecycle. lakecache: {}