# Docker Swarm stack for BETA, co-resident with prod on vps2. # # Deployed by the same deploy/stack/deploy-stack.sh as prod, which picks this # file (and beta's ports, env file, LB and DB role) out of deploy/env-topology.sh # when THERMOGRAPH_ENV=beta. Beta moved here from its own box so that a beta # green light is evidence about prod: same orchestrator, same host kernel, same # Postgres build, same Caddy, same mail path, same mesh position. # # --------------------------------------------------------------------------- # WHY THIS IS A SEPARATE FILE AND NOT AN OVERLAY ON thermograph-stack.yml # --------------------------------------------------------------------------- # `docker stack deploy` accepts multiple -c files and MERGES them. Merging can # add and override, but it cannot REMOVE a service — and the single most # important fact about beta is a removal: it has no `db`. It uses prod's. An # overlay would therefore still create a second Postgres, which is the exact # thing this design exists to avoid. The same goes for the two autoscalers, # which beta deliberately does not run. # # The cost is a file that must be kept roughly in step with prod's by hand. # Keep them in step for anything that affects whether the APP works (env vars, # entrypoints, healthchecks, the migrate contract). Do NOT keep them in step on # scale, replicas or resource limits — those differ on purpose (below). # # --------------------------------------------------------------------------- # THE THREE THINGS THAT MAKE CO-RESIDENCY SAFE # --------------------------------------------------------------------------- # 1. SERVICE NAMES ARE PREFIXED (beta-web, not web). Swarm registers a service's # short name as a DNS alias on every network it joins. Beta's tasks share the # `data` network with prod's, so two services both called `web` would make # `web` ambiguous — prod's frontend could resolve a beta task, and vice # versa. The prefix removes the collision without touching prod's stack file. # # 2. THE DATABASE IS SHARED, THE DATA IS NOT. Beta connects to prod's `db` # service as the role `thermograph_beta`, to the database `thermograph_beta`. # That role owns only its own database (see deploy/db/provision-env-db.sh), # so a beta deploy running an unmerged branch — or a migration that goes # wrong — cannot read or write production data. One server is a capacity # decision, not a trust decision. # # 3. NOTHING ELSE IS SHARED BY ACCIDENT. Separate checkout (/opt/thermograph-beta), # separate rendered env file (/etc/thermograph-beta.env), separate stack env # (/etc/thermograph/beta-stack.env), separate volumes, separate loopback # ports (8237/8180 — prod owns 8137/8080), separate LB container, separate # deploy lock and image-tag file. Every one of those is derived in # env-topology.sh rather than repeated here by hand. # # --------------------------------------------------------------------------- # WHAT BETA DELIBERATELY DOES NOT DO # --------------------------------------------------------------------------- # - No autoscaling: fixed 1 replica per service. Beta exists to answer "does # this code work", not "does it scale"; a second replica would only add a # variable prod's rehearsal doesn't need, on a host prod is also using. # - No IndexNow ping and no city-archive warm (deploy-stack.sh gates both on # TG_POST_DEPLOY). Pinging IndexNow from beta asks Bing/DuckDuckGo/Yandex to # index beta.thermograph.org; the warm spends the shared upstream archive # quota to fill a cache only a rehearsal reads. # - No real mail and no Discord gateway. Both are governed by beta's vault # (THERMOGRAPH_MAIL_BACKEND=console, THERMOGRAPH_DISCORD_BOT=0) rather than # pinned here, so the operator can opt in with `sops edit` if a release ever # genuinely needs to rehearse them. Discord in particular allows ONE gateway # connection per bot token — beta and prod must never both hold one. services: beta-web: image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} entrypoint: ["/host/env-entrypoint.sh"] environment: # Beta's OWN role and OWN database on the shared instance. `db` resolves # across the external `data` network to prod's db service. THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph_beta:${POSTGRES_PASSWORD}@db:5432/thermograph_beta THERMOGRAPH_BASE: / PORT: 8137 THERMOGRAPH_SERVICE_ROLE: backend THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://beta-frontend:8080 WORKERS: ${BETA_WEB_WORKERS:-2} THERMOGRAPH_DATA_DIR: /state # Never the notifier/scheduler — that is beta-worker's job, exactly as in # prod, so the two files stay honest about which process owns what. THERMOGRAPH_ROLE: web # Migrations run as the one-shot task in deploy-stack.sh. RUN_MIGRATIONS: "0" # Overlay tasks reach the HOST's Postfix via the docker_gwbridge gateway. # Same host, same Postfix as prod — but see the mail note in the header: # beta's vault selects the console backend, so nothing is actually sent. THERMOGRAPH_SMTP_HOST: ${STACK_SMTP_HOST:-172.18.0.1} THERMOGRAPH_LAKE_URL: http://beta-lake:8141 volumes: - appdata:/state - applogs:/app/logs - /opt/thermograph-beta/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro - /etc/thermograph/beta-stack.env:/host/thermograph.env:ro networks: - internal - data deploy: replicas: 1 # vps2 is the Swarm manager and every volume here is local to it. The # desktop is a worker on this mesh and must never be scheduled the app. placement: constraints: ["node.role == manager"] resources: limits: cpus: "${BETA_WEB_CPUS:-2}" restart_policy: condition: on-failure update_config: order: start-first failure_action: rollback beta-worker: image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} entrypoint: ["/host/env-entrypoint.sh"] environment: THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph_beta:${POSTGRES_PASSWORD}@db:5432/thermograph_beta THERMOGRAPH_BASE: / PORT: 8137 THERMOGRAPH_SERVICE_ROLE: backend THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://beta-frontend:8080 WORKERS: "1" THERMOGRAPH_DATA_DIR: /state THERMOGRAPH_ROLE: worker # The advisory lock is taken in beta's OWN database, so it can never # contend with prod's worker despite the shared server. THERMOGRAPH_SINGLETON_PG: "1" RUN_MIGRATIONS: "0" THERMOGRAPH_SMTP_HOST: ${STACK_SMTP_HOST:-172.18.0.1} THERMOGRAPH_LAKE_URL: http://beta-lake:8141 volumes: - appdata:/state - applogs:/app/logs - /opt/thermograph-beta/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro - /etc/thermograph/beta-stack.env:/host/thermograph.env:ro networks: - internal - data deploy: replicas: 1 placement: constraints: ["node.role == manager"] resources: limits: cpus: "${BETA_WORKER_CPUS:-1}" restart_policy: condition: on-failure beta-lake: image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/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: # Beta's own cache volume. Deliberately not prod's: they are read caches # of the same bucket, but sharing a volume across two stacks would couple # their lifecycles for no gain. - lakecache:/state - /opt/thermograph-beta/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro - /etc/thermograph/beta-stack.env:/host/thermograph.env:ro # No `data` network: the lake reads object storage, never Postgres. networks: - internal deploy: replicas: 1 placement: constraints: ["node.role == manager"] resources: limits: cpus: "${BETA_LAKE_CPUS:-1}" restart_policy: condition: on-failure update_config: order: start-first failure_action: rollback beta-daemon: image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-jinemi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required} # Same reasoning as prod's daemon: NOT env-entrypoint.sh, because that shim # execs the image's own entrypoint (Alembic + uvicorn) and migrations belong # to the one-shot task. Source the host-rendered env and exec the binary. # # Note the ordering consequence, which is load-bearing here: this sources # the env file AFTER the `environment:` block is applied, so a key present # in /etc/thermograph/beta-stack.env WINS over one set below. That is why # THERMOGRAPH_DISCORD_BOT=0 lives in beta's vault and not in this file — a # value set here would be silently overridden if the vault ever set one. entrypoint: - /bin/bash - -c - 'set -a; [ -f /host/thermograph.env ] && . /host/thermograph.env; set +a; exec /usr/local/bin/thermograph-daemon' environment: THERMOGRAPH_API_BASE_INTERNAL: http://beta-web:8137 volumes: - /etc/thermograph/beta-stack.env:/host/thermograph.env:ro # The image HEALTHCHECK curls /healthz on ${PORT}; the daemon serves # nothing, so without this override Swarm restarts it forever. healthcheck: disable: true networks: - internal deploy: # EXACTLY 1, and in beta's case the Discord gateway is off entirely # (vault: THERMOGRAPH_DISCORD_BOT=0) because prod's daemon holds the only # permitted gateway connection for that bot token. replicas: 1 placement: constraints: ["node.role == manager"] resources: limits: cpus: "0.5" memory: 128m restart_policy: condition: on-failure update_config: order: stop-first failure_action: rollback beta-frontend: image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-jinemi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:?required} entrypoint: ["/host/env-entrypoint.sh"] # REQUIRED: overriding `entrypoint:` with no `command:` drops the image's # CMD entirely, and env-entrypoint.sh's fallback (`exec uvicorn app:app`) # does not exist in this Go image — the task would exit 127 every deploy. command: ["/usr/local/bin/thermograph-frontend"] environment: THERMOGRAPH_BASE: / PORT: 8080 THERMOGRAPH_SERVICE_ROLE: frontend THERMOGRAPH_API_BASE_INTERNAL: http://beta-web:8137 volumes: - /opt/thermograph-beta/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro - /etc/thermograph/beta-stack.env:/host/thermograph.env:ro networks: - internal deploy: replicas: 1 placement: constraints: ["node.role == manager"] resources: limits: cpus: "${BETA_FRONTEND_CPUS:-1}" restart_policy: condition: on-failure update_config: order: start-first failure_action: rollback networks: # Beta's own east-west network: beta-web <-> beta-frontend <-> beta-lake, and # the loopback LB bridge joins it (hence attachable). Keeping this separate # from prod's overlay means beta's ordinary traffic never touches it. internal: driver: overlay attachable: true # Prod's overlay, joined ONLY to reach the shared `db` service. Declared # external because prod's stack owns it: `docker stack deploy` of THIS file # must never create, modify or (on `docker stack rm thermograph-beta`) remove # the network prod's database is on. # # Consequence worth knowing before you tear anything down: `docker stack rm # thermograph` would take this network with it and beta would lose its # database link until prod is redeployed. data: external: true name: thermograph_internal volumes: # Beta's own, created by this stack under the thermograph-beta_ prefix. Unlike # prod's (which are `external` because they were inherited from the compose # era and hold live data), these can be recreated: beta's appdata is a parquet # cache plus derived files, and its DATABASE — the part that matters — lives # on the shared instance, not here. appdata: {} applogs: {} lakecache: {}