# Docker Swarm stack for the hop-1 interim cutover — see # docs/runbooks/hop1-forgejo-registry-cutover.md (which this file implements) and # docs/architecture/repo-topology-and-infrastructure.md §6/§9. # # Distinct from docker-compose.yml (today's plain-compose deploy, unaffected by # this file): Swarm pulls a pre-built image from the registry (IMAGE_TAG) rather # than building in place, and needs Swarm-specific keys (deploy:, networks:, # secrets:, no host-published ports on the overlay-fronted services). # # docker stack deploy -c docker-stack.yml thermograph # # `docker stack deploy` only interpolates from the invoking shell's environment, # not a .env file — export the required vars first (or `set -a; . ./stack.env; # set +a; docker stack deploy ...`). Required shell vars: IMAGE_TAG (the tag CI # pushed to the registry), TIMESCALEDB_TAG (an EXACT pinned minor — see the db # service below, hazard #7). POSTGRES_PASSWORD is NOT a shell var here: unlike # docker-compose.yml (which interpolates it into THERMOGRAPH_DATABASE_URL at # compose time), this file reads it as the `postgres_password` Swarm secret — # POSTGRES_PASSWORD_FILE for db (native support in the postgres/timescaledb # image), and the chunk-3 entrypoint shim builds THERMOGRAPH_DATABASE_URL for # app/worker from the same secret file at container start. All `postgres_password` # / `thermograph_*` secrets below must already exist in the Swarm (`docker secret # create -`) before the first deploy — Track B step 7 in # docs/runbooks/implementation-handoff.md. # # Migrations are NOT run inline by app/worker here (RUN_MIGRATIONS=0) — the # runbook's Stage F brings the schema to head via a one-shot task BEFORE scaling # app up, so multiple replicas never race Alembic and nothing runs DDL against a # still-read-only standby mid-cutover (hazard #9): # # docker run --rm --network thermograph_internal \ # -e THERMOGRAPH_DATABASE_URL=... migrate services: db: # Pin the EXACT TimescaleDB minor — never latest-pg18 here. A floating tag can # give two hosts different extension minors, which blocks a physical replica # (a newer .so over an older catalog won't start) and risks compressed-chunk # corruption on timescaledb_post_restore() (hazard #7). Get the exact X.Y.Z # from the source database: SELECT extversion FROM pg_extension WHERE # extname='timescaledb' — use the SAME tag docker-compose.yml's TIMESCALEDB_TAG # is pinned to, on every host that could ever replicate with this one. image: timescale/timescaledb:${TIMESCALEDB_TAG:?set TIMESCALEDB_TAG to an exact pinned minor, e.g. 2.17.2-pg18 -- not latest-pg18} environment: POSTGRES_USER: thermograph POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password POSTGRES_DB: thermograph DB_MEMORY: ${DB_MEMORY:-8g} volumes: - pgdata:/var/lib/postgresql - ./deploy/db/init:/docker-entrypoint-initdb.d networks: - internal secrets: - postgres_password deploy: # Pin to the labelled DB node so replication/IO never crosses the slow WG # uplink (hazard #14): `docker node update --label-add db=true ` once, # on whichever node holds pgdata (Track B). placement: constraints: ["node.labels.db == true"] resources: limits: cpus: "${DB_CPUS:-2}" memory: ${DB_MEMORY:-8g} restart_policy: condition: on-failure # No published port: reachable only as db:5432 on the `internal` overlay — # Postgres must never be public (design doc §6). # Stateless, freely-replicable web tier. ROLE=web means _should_run_notifier() # is False unconditionally (web/app.py) — it never starts the notifier or the # worker scheduler even if it would otherwise win leader election. app: image: ${IMAGE_TAG:?set IMAGE_TAG to the image CI pushed, e.g. forge.example/thermograph/app:sha-abc123} environment: THERMOGRAPH_BASE: / PORT: 8137 WORKERS: ${WORKERS:-4} THERMOGRAPH_ROLE: web RUN_MIGRATIONS: "0" volumes: - appdata:/app/data - applogs:/app/logs networks: - internal secrets: - postgres_password - thermograph_auth_secret - thermograph_vapid_private_key - thermograph_vapid_public_key deploy: replicas: 1 # single replica this hop -- homepage.json now lives in # Postgres (chunk 4), but the notifier/scheduler split (chunk # 2/5) is what actually lets this go multi-replica in Phase 2 resources: limits: cpus: "${APP_CPUS:-4}" restart_policy: condition: on-failure update_config: order: start-first # new task must pass the image's HEALTHCHECK (now # GET /healthz) before the old one is stopped # No `ports:` — 127.0.0.1:8137:8137 (docker-compose.yml) has no Swarm # equivalent: Swarm's routing mesh publishes on 0.0.0.0, which would expose # the plaintext app un-fronted (hazard #6). Only the host's Caddy reaches # `app`, over the `internal` overlay network — see the Caddyfile templates' # health-gated reverse_proxy. # Owns the notifier + worker scheduler (chunks 1/2/5). Exactly one replica — # the leader-election guard is belt-and-suspenders, not a substitute for it. worker: image: ${IMAGE_TAG:?set IMAGE_TAG to the image CI pushed, e.g. forge.example/thermograph/app:sha-abc123} environment: THERMOGRAPH_BASE: / WORKERS: "1" THERMOGRAPH_ROLE: worker # Cluster-wide advisory lock, not the flock: the flock only arbitrates # workers on ONE host, so under Swarm it guards nothing across replicas. THERMOGRAPH_SINGLETON_PG: "1" RUN_MIGRATIONS: "0" volumes: - appdata:/app/data - applogs:/app/logs networks: - internal secrets: - postgres_password - thermograph_auth_secret - thermograph_vapid_private_key - thermograph_vapid_public_key - thermograph_discord_webhook deploy: replicas: 1 resources: limits: cpus: "${WORKER_CPUS:-1}" restart_policy: condition: on-failure # No `ports:` — the worker serves no public traffic; GET /healthz on its own # container port is for the image's own HEALTHCHECK (Swarm task health) only. networks: internal: driver: overlay driver_opts: # VXLAN-over-WireGuard double encapsulation needs a lower MTU than the # ~1450 overlay default, or large payloads (a /cell bundle, the homepage # feed) silently stall while small packets (health checks) keep passing # (hazard #13). Validate with a real large-payload transfer, not just a # ping, once the mesh is up (Track B). com.docker.network.driver.mtu: "1370" volumes: pgdata: {} appdata: {} applogs: {} # Declared here, provisioned externally (docker secret create - < file) — # never by this stack, and never committed. See Stage 0 of the cutover runbook # for which values are continuity-critical (AUTH_SECRET, VAPID, POSTGRES_PASSWORD # must be the EXISTING live values, not freshly generated ones). secrets: postgres_password: external: true thermograph_auth_secret: external: true thermograph_vapid_private_key: external: true thermograph_vapid_public_key: external: true thermograph_discord_webhook: external: true