Files
scrabble-game/deploy/docker-compose.yml
T
Ilia Denisov 14918cc94f
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
feat(offline): implicit net-state model, two-tier version gate, unified lobby
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs.

Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.

Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
2026-07-13 02:09:13 +02:00

655 lines
31 KiB
YAML

# 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
# Built from postgres:17-alpine + pgBackRest (deploy/postgres/Dockerfile) so the prod
# main host can archive WAL for point-in-time recovery. Archiving is switched on only by
# the prod overlay (docker-compose.prod.yml); on this contour the extra binary is present
# but idle and this is a plain postgres. See deploy/README.md (point-in-time recovery).
image: scrabble-postgres:latest
build:
context: postgres
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"
# The offer page (GET /offer/) fetches the live catalog price list from the backend's internal
# endpoint and splices it into the committed offer markdown. Backend down ⇒ /offer/ returns 502.
RENDERER_BACKEND_URL: http://backend:8080
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"
# Transactional email (confirm-codes) via the shared Selectel relay. An empty
# host makes the backend log codes instead of sending them (dev default). TLS is
# implicit on port 465 and STARTTLS otherwise, unless SMTP_RELAY_TLS forces it
# (ssl|starttls) — needed for Selectel's non-standard ports (1127 = SSL, 1126 =
# STARTTLS). The From uses the prod domain (Selectel only accepts the verified
# sender domain), so it is the same on every contour. PUBLIC_BASE_URL is the
# canonical origin for links in the email (never a request Host header) —
# required whenever the relay host is set.
BACKEND_SMTP_HOST: ${SMTP_RELAY_HOST:-}
BACKEND_SMTP_PORT: ${SMTP_RELAY_PORT:-465}
BACKEND_SMTP_TLS: ${SMTP_RELAY_TLS:-}
BACKEND_SMTP_USERNAME: ${SMTP_RELAY_USER:-}
BACKEND_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-}
BACKEND_SMTP_FROM: ${SMTP_RELAY_FROM:-no-reply@localhost}
BACKEND_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
# Operator alert emails (new feedback / word complaints): a distinct sender and the
# recipient(s) (comma-separated allowed). Both empty disables the alert worker.
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-}
# Direct-rail (Robokassa) payment intake: the merchant login + Password1/Password2 and the
# test-mode flag (deploy env: TEST_/PROD_BACKEND_ROBOKASSA_*). An empty login leaves the
# direct order and Result-callback endpoints unregistered (the rail is off).
BACKEND_ROBOKASSA_MERCHANT_LOGIN: ${ROBOKASSA_MERCHANT_LOGIN:-}
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-}
# 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_VK_APP_ID: ${VITE_VK_APP_ID:-}
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev}
# The rewarded-ad test stub (1 = a toast instead of a real ad; the test contour only, empty
# elsewhere so production shows real ads).
VITE_ADS_STUB: ${VITE_ADS_STUB:-}
# 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
# Client-version gate (ARCHITECTURE.md §2): the hard minimum + the soft recommended client build.
# Empty ⇒ dormant. Plain (unprefixed) — the same value serves every contour; in the test contour
# the stamped client version is a commit hash (unparseable ⇒ fail-open), so the gate only bites
# real semver builds in prod. Validated at gateway start (recommended must be ≥ min).
GATEWAY_MIN_CLIENT_VERSION: ${GATEWAY_MIN_CLIENT_VERSION:-}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${GATEWAY_RECOMMENDED_CLIENT_VERSION:-}
# 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:-}
# VK ID web login (browser VK-identity linking): the confidential OAuth 2.1 code
# exchange runs server-side under the VK ID "Web" app's protected key. This is a
# SEPARATE VK app from the Mini App above, so the credentials are distinct. The app id
# and redirect URL are the same values the SPA builds its authorize URL from (one
# source each). All three empty disables the link.vk.* ops.
GATEWAY_VK_ID_APP_ID: ${VITE_VK_APP_ID:-}
GATEWAY_VK_ID_CLIENT_SECRET: ${GATEWAY_VK_ID_CLIENT_SECRET:-}
GATEWAY_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
# 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 still logs). The prod deploy forces it on
# in env.sh (deploy/write-prod-env.sh), not via a Gitea variable. GATEWAY_HONEYTOKEN
# is the planted bearer trap, fed from the per-contour TEST_/PROD_GATEWAY_HONEYTOKEN
# secret; empty (unset secret) leaves the trap off.
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
# Community IP blocklist (Spamhaus DROP): prod-only, off unless the real client IP is visible
# (the shared-NAT test contour would self-block). Enabled + fed the feed URL + allowlist by the
# prod deploy (write-prod-env.sh); the refresh/staleness windows use the built-in defaults.
GATEWAY_BLOCKLIST_ENABLED: ${GATEWAY_BLOCKLIST_ENABLED:-false}
GATEWAY_BLOCKLIST_URL: ${GATEWAY_BLOCKLIST_URL:-}
GATEWAY_BLOCKLIST_ALLOW: ${GATEWAY_BLOCKLIST_ALLOW:-}
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_VK_APP_ID: ${VITE_VK_APP_ID:-}
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
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 Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
# Stars rail (pre_checkout + successful_payment + the durable outbox); empty disables it. The
# rail stays inert until a chip pack carries an XTR (Stars) price, so it is safe to leave on.
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_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
# Maintenance page + toggle flag: the caddy config dir holds maintenance.html and the
# `on` flag prod-deploy.sh touches around a rolling swap; the Caddyfile serves a 503
# from here while the flag exists (read-only mount — the deploy writes the flag on the
# host side). See deploy/caddy/Caddyfile and deploy/prod-deploy.sh.
- ${SCRABBLE_CONFIG_DIR:-.}/caddy:/srv/maint: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"
# SMTP for alert emails, reusing the shared relay host + credentials + the SERVICE
# From/recipient. Grafana's client speaks STARTTLS (not the backend's implicit-TLS
# port), so it dials the relay host on its STARTTLS port (GRAFANA_SMTP_PORT).
# Disabled unless GF_SMTP_ENABLED.
GF_SMTP_ENABLED: ${GF_SMTP_ENABLED:-false}
GF_SMTP_HOST: ${SMTP_RELAY_HOST:-}:${GRAFANA_SMTP_PORT:-}
GF_SMTP_USER: ${SMTP_RELAY_USER:-}
GF_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-}
# A BARE address (Grafana rejects the "Name" <addr> form); the deploy derives it from
# SMTP_RELAY_SERVICE_FROM, splitting off the display name into GRAFANA_SMTP_FROM_NAME.
GF_SMTP_FROM_ADDRESS: ${GRAFANA_SMTP_FROM_ADDRESS:-}
GF_SMTP_FROM_NAME: ${GRAFANA_SMTP_FROM_NAME:-Erudit Alerts}
GF_SMTP_STARTTLS_POLICY: MandatoryStartTLS
# The alert recipient(s), read by the provisioned contact point via $__env{SERVICE_EMAIL}.
SERVICE_EMAIL: ${SERVICE_EMAIL:-}
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]
# blackbox_exporter lets Prometheus alert on TLS certificate expiry (a Caddy ACME
# renewal failure) via probe_ssl_earliest_cert_expiry. It probes the edge caddy that
# terminates TLS (prod: the published scrabble-caddy; the test contour has no compose
# caddy, so the probe simply finds no target and the cert metric is absent — the rule is
# absent-safe). See prometheus.yml and grafana alerting rules.
blackbox_exporter:
container_name: scrabble-blackbox-exporter
image: prom/blackbox-exporter:v0.25.0
restart: unless-stopped
logging: *default-logging
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
deploy:
resources:
limits:
memory: 64M
networks: [internal, edge]
networks:
internal:
name: scrabble-internal
edge:
external: true
volumes:
postgres-data:
dawg-data:
caddy-data:
prometheus-data:
tempo-data:
grafana-data:
bot-state: