# Full deploy descriptor for the Scrabble test contour: backend + gateway + # Postgres + the Telegram validator + bot (the bot with its VPN sidecar) + the # observability stack (OTel Collector -> Prometheus + Tempo -> Grafana). Driven by # .gitea/workflows/ci.yaml (`docker compose up -d --build`); env values are # interpolated from Gitea Actions TEST_ secrets/variables exported by the deploy # job (see deploy/.env.example for the unprefixed names). # # Config bind sources are prefixed with ${SCRABBLE_CONFIG_DIR:-.}: locally they bind # straight from this directory, but CI seeds them to a stable host path and sets # SCRABBLE_CONFIG_DIR to it, because the runner's checkout is ephemeral (act removes # it after the job) and the bind mounts must outlive the job in the long-running # containers (see .gitea/workflows/ci.yaml + deploy/README.md). # # Networking (mirrors ../galaxy-game): # - `internal` (scrabble-internal): all inter-service traffic, project-private # DNS so service names never collide on the shared `edge` network. # - `edge` (external): the host caddy reaches this contour at `scrabble:80` # (the in-compose caddy's alias). The in-compose caddy terminates only HTTP in # the test contour; the host caddy terminates TLS and forwards. For prod # (no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy # does its own ACME — the contour is then self-contained. # - The validator answers internal gRPC at `validator:9091` (no VPN, HMAC only). # The bot egresses to api.telegram.org through the `vpn` sidecar (network_mode: # service:vpn) and dials the gateway bot-link (mTLS) at `gateway:9443`. The # backend admin relay reaches the gateway at `gateway:9092` (plaintext). name: scrabble # Bound every container's json-file logs. The backend emits a per-request latency # line at info (~14 MiB / 30 min under the 500-player peak); without rotation the # volume grows unbounded. 10 MiB x 3 files caps each # container at 30 MiB. Applied to every service via the *default-logging alias. x-logging: &default-logging driver: json-file options: max-size: "10m" max-file: "3" services: postgres: container_name: scrabble-postgres image: postgres:17-alpine restart: unless-stopped logging: *default-logging environment: POSTGRES_DB: ${POSTGRES_DB:-scrabble} POSTGRES_USER: ${POSTGRES_USER:-scrabble} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scrabble} -d ${POSTGRES_DB:-scrabble}"] interval: 5s timeout: 3s retries: 30 volumes: - postgres-data:/var/lib/postgresql/data # 512M leaves headroom over the default 128 MB shared_buffers + per-connection # memory (the load harness peaked at 28 backends / 69 MiB RSS). deploy: resources: limits: cpus: "2.0" memory: 512M networks: [internal] # The finished-game image-render sidecar: internal-only, called by the backend for the # PNG export artifact. It runs the same ui/src/lib/gameimage.ts the web client # unit-tests, on skia-canvas (renderer/README.md). node:22-slim carries a shell, so — # uniquely among the built services — it has a real container healthcheck the backend's # depends_on gates on. renderer: container_name: scrabble-renderer image: scrabble-renderer:latest build: context: .. dockerfile: renderer/Dockerfile args: VERSION: ${APP_VERSION:-dev} restart: unless-stopped logging: *default-logging environment: RENDERER_PORT: "8090" healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 10s timeout: 3s retries: 12 # A render peaks well under 100 MiB (a 2-MP canvas + skia); idle sits near 60 MiB. deploy: resources: limits: cpus: "1.0" memory: 192M networks: [internal] backend: container_name: scrabble-backend image: scrabble-backend:latest build: context: .. dockerfile: backend/Dockerfile args: # Seed dictionary for a FRESH volume; required (no default) so the release tag is # set in exactly one place per context — the deploy env (Gitea TEST_/PROD_DICT_VERSION) # or .env for local builds. See the volume note below + deploy/README.md "Bumping the # dictionary version". DICT_VERSION: ${DICT_VERSION:?set DICT_VERSION — the scrabble-dictionary release tag, e.g. in deploy/.env} # Build version stamped into the binary (git tag; see pkg/version). VERSION: ${APP_VERSION:-dev} restart: unless-stopped logging: *default-logging depends_on: postgres: condition: service_healthy renderer: condition: service_healthy environment: # search_path=backend matches the migrations (00001 creates the schema). BACKEND_POSTGRES_DSN: postgres://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable&search_path=backend # The finished-game export: the render sidecar address + the HMAC key signing the # public download URLs (deploy env: TEST_/PROD_EXPORT_SIGN_KEY). BACKEND_RENDERER_URL: http://renderer:8090 BACKEND_EXPORT_SIGN_KEY: ${EXPORT_SIGN_KEY:?set EXPORT_SIGN_KEY — the export download URL signing secret} # The pool caps at 25 conns (~28 backends) around 500 players; 40 gives headroom # for bursts. Postgres (2 cores / 512 MiB) handles it. BACKEND_POSTGRES_MAX_OPEN_CONNS: "40" BACKEND_HTTP_ADDR: ":8080" BACKEND_GRPC_ADDR: ":9090" # Admin broadcasts go to the gateway's bot-link relay, which forwards them to # the remote bot and reports back whether they were delivered. BACKEND_CONNECTOR_ADDR: gateway:9092 BACKEND_LOG_LEVEL: ${LOG_LEVEL:-info} BACKEND_SERVICE_NAME: scrabble-backend BACKEND_OTEL_TRACES_EXPORTER: otlp BACKEND_OTEL_METRICS_EXPORTER: otlp OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317 OTEL_EXPORTER_OTLP_INSECURE: "true" # GOMAXPROCS matches the CPU limit below so the Go scheduler aligns with the # cgroup quota (the runtime otherwise sees all of the host's cores). GOMAXPROCS: "2" # The dictionary lives on a named volume seeded from the image on first boot # (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume # inherits). The admin console writes new version subdirectories here, and the # volume preserves them — and the versions in-progress games pin — across # redeploys. Once seeded the volume is not re-seeded: DICT_VERSION is the seed for # a FRESH volume only. On a live volume a changed DICT_VERSION is ignored (the # recorded .seed_version marker wins — the seed-drift guard), so a running contour's # dictionary is changed through the admin console, not by bumping the seed # (docs/ARCHITECTURE.md §5). volumes: - dawg-data:/opt/dawg # No container healthcheck: the distroless image has no shell/wget. Readiness # is covered by the CI post-deploy probe (GET / through caddy). # Generous over the ~1-core / <=100 MiB measured peak; the prod overlay trims these # to the launch-host values. deploy.resources.limits is # honoured by `docker compose up` (Compose v2), not only by swarm. deploy: resources: limits: cpus: "2.0" memory: 512M networks: [internal] gateway: container_name: scrabble-gateway image: scrabble-gateway:latest build: context: .. dockerfile: gateway/Dockerfile target: gateway args: VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-} VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_APP_VERSION: ${APP_VERSION:-dev} # Go binary version (the SPA's VITE_APP_VERSION is the same git tag). VERSION: ${APP_VERSION:-dev} restart: unless-stopped logging: *default-logging depends_on: [backend] environment: GATEWAY_HTTP_ADDR: ":8081" GATEWAY_BACKEND_HTTP_URL: http://backend:8080 GATEWAY_BACKEND_GRPC_ADDR: backend:9090 # Telegram auth validates against the home validator (plaintext, internal). GATEWAY_VALIDATOR_ADDR: validator:9091 # VK Mini App auth verifies the launch-parameter signature in-process under the VK # app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables # the VK auth path (auth.vk is then unregistered). GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-} # The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay # reaches the gateway at :9092 (plaintext, internal). In the test contour both # listeners stay on the internal network (the bot shares the VPN netns); in prod # the bot is a separate host and :9443 is published with public certificates. GATEWAY_BOTLINK_ADDR: ":9443" GATEWAY_BOTLINK_RELAY_ADDR: ":9092" GATEWAY_BOTLINK_TLS_CERT: /certs/gateway.crt GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt # Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the # honeypot/honeytoken. Off by default: it bans by client IP, which is only # real in prod — the test contour arrives as one shared NAT address, so a ban # there would be self-inflicted (the honeypot/honeytoken still log). Prod sets # these from PROD_ inputs; GATEWAY_HONEYTOKEN is the planted bearer trap. GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false} GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-} GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info} GATEWAY_SERVICE_NAME: scrabble-gateway GATEWAY_OTEL_TRACES_EXPORTER: otlp GATEWAY_OTEL_METRICS_EXPORTER: otlp OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317 OTEL_EXPORTER_OTLP_INSECURE: "true" # GOMAXPROCS matches the CPU limit below (see backend). GOMAXPROCS: "3" # GATEWAY_ADMIN_* intentionally unset: in the deployed contour the front # caddy owns the /_gm Basic-Auth and routes /_gm to the backend directly. # The bot-link mTLS material (CA + gateway server leaf). Generated by # deploy/gen-certs.sh for the test contour; supplied from PROD_ secrets in prod. volumes: - ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro # The gateway holds one h2c connection per player, so at 500 players it # bursts into a 2-core cap (~2.49% transport_error on game.state); 3 cores absorbs # the bursts. Per-connection overhead is the realistic prod cost — size for it. deploy: resources: limits: cpus: "3.0" memory: 512M networks: [internal] # --- Landing (static) ------------------------------------------------------- # The public landing page in its own caddy container: the contour caddy # routes the catch-all (notably /) here, the gateway keeps only /app/, # /telegram/ and the Connect edge. Shares the gateway Dockerfile's UI build # stage — identical build args keep that stage a single cached build. landing: container_name: scrabble-landing image: scrabble-landing:latest build: context: .. dockerfile: gateway/Dockerfile target: landing args: VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-} VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_APP_VERSION: ${APP_VERSION:-dev} restart: unless-stopped logging: *default-logging deploy: resources: limits: memory: 128M networks: [internal] # --- Telegram validator (home; HMAC only, no VPN, no Telegram egress) ------- # The validator holds the bot token solely as the HMAC secret and never reaches # the Bot API, so it runs on the main network with no VPN. Game login validates # against it and stays up even when the remote bot or the bot-link is down. validator: container_name: scrabble-telegram-validator image: scrabble-telegram-validator:latest build: context: .. dockerfile: platform/telegram/Dockerfile target: validator args: VERSION: ${APP_VERSION:-dev} restart: unless-stopped logging: *default-logging environment: # The token is the HMAC secret only; the validator never calls the Bot API. An # empty value crash-loops only the validator; the rest of the contour comes up. TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_VALIDATOR_GRPC_ADDR: ":9091" TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info} TELEGRAM_SERVICE_NAME: scrabble-telegram-validator TELEGRAM_OTEL_TRACES_EXPORTER: otlp TELEGRAM_OTEL_METRICS_EXPORTER: otlp OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317 OTEL_EXPORTER_OTLP_INSECURE: "true" GOMAXPROCS: "1" deploy: resources: limits: cpus: "1.0" memory: 128M networks: [internal] # --- Telegram bot (egress via the VPN sidecar in test; dials the gateway) --- # vpn + bot are gated to the `telegram-local` profile: the test contour runs them # locally (CI passes --profile telegram-local), the prod main host omits them, and # the prod bot runs on its own host from deploy/docker-compose.bot.yml. vpn: container_name: scrabble-telegram-vpn image: docker.iliadenisov.ru/developer/amneziawg-sidecar:latest profiles: ["telegram-local"] restart: unless-stopped logging: *default-logging privileged: true environment: # Required by the vpn sidecar, which is gated to the telegram-local profile. # Compose can't scope a `:?` guard to a profile (interpolation runs for # profiled-out services too) and the prod main host has no VPN, so this is a soft # default; the test contour always supplies TEST_AWG_CONF and the sidecar validates it. AWG_CONF: ${AWG_CONF:-} networks: internal: aliases: [telegram] bot: container_name: scrabble-telegram-bot image: scrabble-telegram-bot:latest profiles: ["telegram-local"] build: context: .. dockerfile: platform/telegram/Dockerfile target: bot args: VERSION: ${APP_VERSION:-dev} restart: unless-stopped logging: *default-logging depends_on: [vpn] network_mode: "service:vpn" environment: # The bot token lives on the bot host (ARCHITECTURE.md §12). The bot requires it # at boot; an empty value leaves the bot down while the rest of the contour comes up. TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-} # The moderated discussion chat (a channel's linked group) the bot gates write # access in. Empty disables gating. The group must ALLOW sending by default — the bot # only restricts (mutes the ineligible) — and the bot must be an admin there with the # "Ban users" right; chat_member updates are delivered only to a chat admin. TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} # The private forum supergroup the bot relays direct user messages into (one topic # per user) and reads operator replies from. Empty disables the support relay; when # set the bot must be an admin there with the manage-topics and delete-messages rights. TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-} TELEGRAM_SUPPORT_STATE_DIR: /data # The optional standalone promo bot (its own token) answering /start with a button # into the main bot's app. Empty disables it; when set it needs the main bot's # @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK). TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-} TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-} TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL} TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false} TELEGRAM_API_BASE_URL: ${TELEGRAM_API_BASE_URL:-} TELEGRAM_OWNS_UPDATES: "true" # The bot dials the gateway bot-link over mTLS. In test it reaches the gateway by # its internal service name through the VPN netns (Docker resolver, off-tunnel, # like otelcol); in prod it is a separate host dialing the gateway's public port. TELEGRAM_GATEWAY_ADDR: gateway:9443 TELEGRAM_BOTLINK_SERVER_NAME: gateway TELEGRAM_BOTLINK_TLS_CERT: /certs/bot.crt TELEGRAM_BOTLINK_TLS_KEY: /certs/bot.key TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info} TELEGRAM_SERVICE_NAME: scrabble-telegram-bot # The bot shares the VPN sidecar's netns. Routing to internal IPs stays off the # tunnel (connected route), but the sidecar's DNS hijacks name resolution: # AWG_CONF must NOT carry a `DNS=` directive, else `otelcol` and `gateway` won't # resolve. Without DNS= the netns uses Docker's resolver, which resolves the # internal services and api.telegram.org (see deploy/README.md). TELEGRAM_OTEL_TRACES_EXPORTER: otlp TELEGRAM_OTEL_METRICS_EXPORTER: otlp OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317 OTEL_EXPORTER_OTLP_INSECURE: "true" # The bot is light (the stress run does not drive Telegram); one P suffices. GOMAXPROCS: "1" volumes: - ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro # Support relay state (topic mapping, block list); survives redeploys. - bot-state:/data deploy: resources: limits: cpus: "1.0" memory: 256M # --- Edge reverse proxy (single /_gm Basic-Auth; SPA + Connect -> gateway; # the catch-all incl. the landing -> the static landing container) ------- caddy: container_name: scrabble-caddy image: caddy:2-alpine restart: unless-stopped logging: *default-logging depends_on: [gateway, backend, grafana, landing] environment: # Test: ":80" (host caddy terminates TLS). Prod: a domain for own ACME. CADDY_SITE_ADDRESS: ${CADDY_SITE_ADDRESS:-:80} GM_BASICAUTH_USER: ${GM_BASICAUTH_USER:-gm} GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH} volumes: - ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data deploy: resources: limits: memory: 128M networks: internal: {} edge: aliases: [scrabble] # --- Observability --------------------------------------------------------- otelcol: container_name: scrabble-otelcol image: otel/opentelemetry-collector-contrib:0.119.0 restart: unless-stopped logging: *default-logging command: ["--config=/etc/otelcol/config.yaml"] # The docker_stats receiver reads per-container metrics from the Docker API, so the # collector (image UID 10001) joins the host's docker group to read the socket — # DOCKER_GID defaults to the contour host's 989; set it for other hosts (prod). The # socket is mounted read-only. This replaces cAdvisor, whose per-container metrics # are empty on this host (separate-XFS /var/lib/docker). group_add: ["${DOCKER_GID:-989}"] volumes: - ${SCRABBLE_CONFIG_DIR:-.}/otelcol/config.yaml:/etc/otelcol/config.yaml:ro - /var/run/docker.sock:/var/run/docker.sock:ro deploy: resources: limits: memory: 512M networks: [internal] prometheus: container_name: scrabble-prometheus image: prom/prometheus:v2.55.1 restart: unless-stopped logging: *default-logging command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.retention.time=15d volumes: - ${SCRABBLE_CONFIG_DIR:-.}/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - prometheus-data:/prometheus deploy: resources: limits: memory: 512M networks: [internal] tempo: container_name: scrabble-tempo image: grafana/tempo:2.7.1 restart: unless-stopped logging: *default-logging command: ["-config.file=/etc/tempo/tempo.yaml"] volumes: - ${SCRABBLE_CONFIG_DIR:-.}/tempo/tempo.yaml:/etc/tempo/tempo.yaml:ro - tempo-data:/var/tempo # Tempo reached the 1 GiB cap under sustained load (446 MiB in earlier runs); # raised to 2 GiB for headroom against OOM. deploy: resources: limits: memory: 2G networks: [internal] grafana: container_name: scrabble-grafana image: grafana/grafana:11.4.0 restart: unless-stopped logging: *default-logging depends_on: [prometheus, tempo] environment: # Served under /_gm/grafana behind caddy's Basic-Auth; anonymous Admin so a # single shared login (caddy) gates it with no per-user Grafana accounts. GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-/_gm/grafana/} GF_SERVER_SERVE_FROM_SUB_PATH: "true" GF_AUTH_ANONYMOUS_ENABLED: "true" GF_AUTH_ANONYMOUS_ORG_ROLE: Admin GF_AUTH_DISABLE_LOGIN_FORM: "true" GF_AUTH_BASIC_ENABLED: "false" GF_USERS_ALLOW_SIGN_UP: "false" GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} # Disable Grafana Live: its WebSocket (/_gm/grafana/api/live/ws) otherwise hits # caddy's Basic-Auth and re-prompts for the password on every dashboard; the # dashboards poll and do not need Live. GF_LIVE_MAX_CONNECTIONS: "0" volumes: - ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro # Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the # grafana-data volume mounts over — a nested bind there is shadowed and the # provider logs "no such file or directory"). - ${SCRABBLE_CONFIG_DIR:-.}/grafana/dashboards:/etc/grafana/dashboards:ro - grafana-data:/var/lib/grafana deploy: resources: limits: memory: 512M networks: [internal] # postgres_exporter exports Postgres server metrics (connections, cache hit ratio, # transactions, database size). Prometheus scrapes it at :9187. The DSN reuses the # contour Postgres credentials; sslmode=disable on the internal network. postgres_exporter: container_name: scrabble-postgres-exporter image: prometheuscommunity/postgres-exporter:v0.16.0 restart: unless-stopped logging: *default-logging depends_on: [postgres] environment: DATA_SOURCE_NAME: postgresql://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable deploy: resources: limits: memory: 128M networks: [internal] # node_exporter exports host CPU/memory/disk metrics. The prod main host runs a tight # 1.9 GiB budget, so host memory pressure — not just per-container docker_stats — is # what warns before an OOM. Prometheus scrapes it at :9100 (see prometheus.yml). node_exporter: container_name: scrabble-node-exporter image: quay.io/prometheus/node-exporter:v1.8.2 restart: unless-stopped logging: *default-logging command: - --path.rootfs=/host - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host)($|/) pid: host volumes: - /:/host:ro,rslave deploy: resources: limits: memory: 64M networks: [internal] networks: internal: name: scrabble-internal edge: external: true volumes: postgres-data: dawg-data: caddy-data: prometheus-data: tempo-data: grafana-data: bot-state: