# Canonical Arena Deployment (VPS2) This is the runbook for the one canonical, public BotFights arena. Every node's local instance can proxy match/fighter state to a shared arena via `ARENA_UPSTREAM_URL` — this document covers deploying the well-known default one, on the Foundation's VPS2 host. Nothing here is git-tracked automatically: nginx-proxy-manager's routing config and the host `.env` (secrets) live only on the VPS2 host. This file is the only record of how to reproduce or roll back the deployment. ## Architecture: arena-as-relay (read this first) BotFights' shared-arena design is intentionally decentralized, not hardcoded to one server: - **Any node can host a public arena.** It's the exact same container image any node already runs — a "public arena" is just a BotFights instance with `ARENA_UPSTREAM_URL` **unset** (standalone mode) that other nodes point at. - **Each node picks its own community** by setting `ARENA_UPSTREAM_URL` in its own manifest/environment. Unset = fully standalone, own local SQLite DB. - **This VPS2 deployment is only the well-known default rendezvous** — like the vps2 FIPS anchor — not an authority baked into the code. Nothing in `botfight`'s server or frontend code hardcodes `146.59.87.168`; it is entirely an environment-variable choice made by whoever configures a node. - The game UI is always served locally by each node; only match/fighter state lives wherever `ARENA_UPSTREAM_URL` points. - **How to host your own arena:** deploy this exact `docker-compose.arena.yml` pattern (or even the node's normal `docker-compose.yml`) anywhere reachable, leave `ARENA_UPSTREAM_URL` unset on it, generate your own `JWT_SECRET`, and point whichever nodes you want in your community at `ARENA_UPSTREAM_URL=http://:`. There is no registration or allowlist step — the protocol is "point at a URL that speaks the BotFights API." ## Current canonical instance | Field | Value | |---|---| | Host | VPS2, `debian@146.59.87.168` (docker, not podman — this is host infra, not an Archipelago node) | | Directory | `/opt/botfights-arena/` | | Compose file | `/opt/botfights-arena/docker-compose.yml` (copied from this repo's `docker-compose.arena.yml`, not symlinked — re-copy after edits) | | Container name | `botfights-arena` | | Image | `localhost:3000/lfg2025/botfights:1.1.0` (Gitea registry on the same host; `localhost:3000` resolves without any insecure-registry config because Docker trusts loopback registries by default — this is why the compose file uses `localhost:3000`, not the public `146.59.87.168:3000`, as the image ref) | | Port | **9100** (verified free before binding; now bound — see `ss -tlnp` output in this phase's execution log) | | Data volume | named volume `botfights-arena-data` → `/app/server/data` inside the container (host mountpoint: `docker volume inspect botfights-arena_botfights-arena-data --format '{{.Mountpoint}}'`) | | **Canonical URL** | **`https://botfights.archipelago-foundation.org`** — TLS via nginx-proxy-manager + Let's Encrypt (user created DNS + proxy host 2026-07-30). Raw fallback: `http://146.59.87.168:9100` | ### Why plain HTTP on the raw port (no DNS/TLS this phase) The user explicitly chose to skip creating a subdomain (e.g. `arena.archipelago-foundation.org`), an nginx-proxy-manager proxy host, and a Let's Encrypt certificate for this phase. Rationale: - The node → arena hop is **server-side** (each node's Hono server proxies `/api/*` to `ARENA_UPSTREAM_URL`), never a browser fetch — so there is no mixed-content restriction that would otherwise force HTTPS. - Cloud bots (server-to-server `curl`/HTTP clients) don't enforce browser-style mixed-content or certificate-pinning either. - This keeps the deploy on the fast path for the 2026-07-31 demo — no DNS propagation wait, no cert-issuance step. **Threat register note (T-09-16, accepted):** credentials (JWT bearer tokens, NIP-98 auth headers, bot secrets) travel in plaintext over `http://146.59.87.168:9100`. This is an accepted, recorded tradeoff, not an oversight. ### Later TLS upgrade path (env-only, no code change) When DNS/TLS is wanted: 1. Add an A record, e.g. `arena.archipelago-foundation.org` → `146.59.87.168` (GoDaddy `ns29/ns30.domaincontrol.com`, no wildcard — this needs its own record). 2. In nginx-proxy-manager (`https://146.59.87.168:81`, admin `lfg2025@proton.me`), add a new **Proxy Host**: - Domain: `arena.archipelago-foundation.org` - Scheme: `http` - Forward Hostname/IP: `146.59.87.168` - Forward Port: `9100` - Block Common Exploits: on - Websockets Support: on (`allow_websocket_upgrade=1` — required for any future websocket use; the current SSE fight-stream is plain HTTP chunked streaming and doesn't strictly need this, but it's the established pattern for every other subdomain on this host) - SSL tab: request a new Let's Encrypt certificate, force SSL (`ssl_forced=1`) — mirrors the existing `demo.`/`source.`/`fips.` hosts. 3. Change **only** the value every node reads: `ARENA_UPSTREAM_URL` in `apps/botfights/manifest.yml` (archy repo) from `http://146.59.87.168:9100` to `https://botfights.archipelago-foundation.org` — DONE 2026-07-30: the user created the DNS A record and the NPM proxy host with a Let's Encrypt cert; `TRUSTED_PROXY=1` was enabled on the arena at the same time (it now sits behind NPM). No code change — the reverse-proxy middleware and NIP-98 verification are both already origin-independent (path-only URL comparison). 4. Optionally keep `:9100` open as a fallback/legacy path, or firewall it down to only `127.0.0.1` once NPM is fronting it (`ports: - "127.0.0.1:9100:9100"` in the compose file) so the raw port is no longer publicly reachable. ## Secret handling `JWT_SECRET` is generated **on the VPS2 host**, never in this repo, never in a compose file value, never printed to a log or transcript: ```bash # On VPS2, inside /opt/botfights-arena/: umask 077 echo "JWT_SECRET=$(openssl rand -hex 32)" > .env chmod 600 .env ``` `docker-compose.arena.yml` only ever references `${JWT_SECRET}` — the literal value lives solely in `/opt/botfights-arena/.env` (mode `0600`, owned by `debian`, outside any git repo). **Rotation:** overwrite `.env` with a freshly-generated value, then `docker compose down && docker compose up -d` (all existing sessions/JWTs become invalid — bot `secret`/`bot_id` pairs used for `POST /api/bots` auth are unaffected, only nostr-signer-issued JWTs expire). **If a secret value is ever accidentally exposed** (e.g. printed by a `docker inspect` command run without redaction): rotate immediately using the steps above. This happened once during this phase's initial deployment (caught and corrected the same session — the secret was rotated and the container restarted before any external use). ## Data seed: full database copy (user decision 2026-07-30) The arena was seeded from archi-dev-box's existing BotFights instance (`/var/lib/archipelago/botfights/botfights.db`, 351 MB at the time of export — 115 bots, 102,440 fights, `payments`/`bets` tables present but empty). **Export method (read-only, source never written to):** ```python # Read-only URI connection — SQLite refuses writes on this handle. # VACUUM INTO produces a compacted, self-consistent snapshot including # any WAL-mode uncommitted-but-checkpointed data, without requiring write # access to the source's -wal/-shm files. import sqlite3 con = sqlite3.connect( 'file:/var/lib/archipelago/botfights/botfights.db?mode=ro', uri=True) con.execute("VACUUM INTO '/path/to/botfights-export.db'") con.close() ``` Source file `mtime`/size were compared before and after the export and confirmed byte-identical (`1782916151`, `367144960` bytes) — the export did not touch the live node's database. **Deploy steps used:** 1. `docker compose stop` on the arena (avoid the app writing to the volume mid-copy). 2. `scp` the exported `.db` file to VPS2, then as root: `cp` it into the named volume's host mountpoint as `botfights.db`, removing any stray `-wal`/`-shm` files from the fresh-start container run. 3. `chown` the file to uid/gid `999` — the container's non-root `botfights` system user (verify with `docker inspect botfights-arena --format '{{.Config.User}}'` and the uid `useradd --system` assigned it, since docker on this host does not use userns-remap — the host uid IS the container uid). 4. `docker compose start`. **Result:** `GET /api/bots` returns **100** rows by default (the endpoint filters out `botType === 'classic'` bots) — the remaining **15** classic-type bots are visible via `GET /api/bots?type=classic`. `100 + 15 = 115`, matching the source exactly. No data was lost; this is existing, unmodified API filtering behavior, not an artifact of the copy. ## Verification (rerun any time to confirm the arena is healthy) ```bash # On-host: ssh debian@146.59.87.168 'curl -fsS http://127.0.0.1:9100/api/health' # → {"status":"ok","name":"botfights"} # Off-host (from archi-dev-box or any client with a path to VPS2): curl -fsS --max-time 10 http://146.59.87.168:9100/api/health curl -fsS --max-time 10 http://146.59.87.168:9100/api/bots # expect 100 (+15 classic) ``` ## Building and pushing `botfights:1.2.0` (plan 09-05) `1.2.0` is the first image built after the arena-proxy middleware (09-01), the nostr-only `GET /api/auth/me` auth fix (09-02), and the unified `GET /api/docs/prompt` AI setup prompt (09-03) all landed on `main`. Build from a clean checkout of `origin/main`: ```bash cd /home/archipelago/Projects/botfight git pull --ff-only origin main # confirm all three wave-1 plans are present before building: test -f server/src/middleware/arena-proxy.ts grep -q "get('/me'" server/src/routes/auth.ts grep -q "get('/prompt'" server/src/routes/docs.ts podman build --build-arg CACHE_BUST=$(date +%s) \ -t 146.59.87.168:3000/lfg2025/botfights:1.2.0 . # Smoke test locally BEFORE pushing (spare port, no upstream configured): podman run --rm -d --name botfights-smoketest -p 9199:9100 \ -e NODE_ENV=production -e JWT_SECRET=$(openssl rand -hex 32) \ 146.59.87.168:3000/lfg2025/botfights:1.2.0 curl -fsS http://127.0.0.1:9199/api/health curl -fsSi http://127.0.0.1:9199/api/docs/prompt | head -3 # expect 200, text/markdown curl -si http://127.0.0.1:9199/api/auth/me | head -3 # expect 401, no Authorization header podman rm -f botfights-smoketest # Push (registry is plain HTTP; 146.59.87.168:3000 is already configured as an # insecure registry in /etc/containers/registries.conf.d/archipelago.conf on # this host, but --tls-verify=false is passed explicitly too): podman login 146.59.87.168:3000 -u lfg2025 -p podman push --tls-verify=false 146.59.87.168:3000/lfg2025/botfights:1.2.0 # Verify from the registry side: skopeo inspect --tls-verify=false docker://146.59.87.168:3000/lfg2025/botfights:1.2.0 ``` **Build gotcha hit this session (pre-existing, unrelated to phase 09's own code — fixed as an in-scope blocking-issue deviation):** `pnpm install --frozen-lockfile` inside the `deps` build stage failed with `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`. Root cause: an earlier commit (`bcb323e`, March 2026) moved dependency `overrides` from `package.json`'s `pnpm.overrides` key (a location modern pnpm no longer reads at all — see its own deprecation warning) to `pnpm-workspace.yaml`'s `overrides:` key, but only migrated 2 of 3 override entries and never regenerated `pnpm-lock.yaml` to match. The Dockerfile's `corepack prepare pnpm@latest` pulls whatever pnpm is current at build time, which enforces the lockfile-vs-config check strictly. Fixed by: removing the dead `pnpm` field from `package.json`, adding the missing `tar: '>=7.5.11'` override to `pnpm-workspace.yaml` (alongside the two already there), and regenerating `pnpm-lock.yaml` with `pnpm install --no-frozen-lockfile` — the resulting lockfile diff contains **zero** `specifier:` changes (verified by grep), only peer-dependency resolution-graph annotations from the newer pnpm version explicitly listing `supports-color` as a peer. `pnpm install --frozen-lockfile` and `tsc --noEmit -p server/tsconfig.json` both pass clean against the regenerated lockfile. **Result (this session, 2026-07-31):** | Field | Value | |---|---| | Tag | `146.59.87.168:3000/lfg2025/botfights:1.2.0` | | Digest | `sha256:854ea299...26e144` (short form; full digest recorded in `.planning/phases/09-botfights-platform-upgrade/09-05-SUMMARY.md` — re-derive any time with `skopeo inspect` above) | | Built from | `botfight` repo `main` @ the commit carrying the `GET /api/fights/poll` route-order fix below (`2a343ac` + fix commit) | | Local smoke test | `/api/health` → `{"status":"ok",...}`; `/api/docs/prompt` → 200 `text/markdown`; `/api/auth/me` (no auth) → 401; `/api/fights/poll` (registered bot) → 200 `{"pending":false}` | **Deviation fixed in the same build pass:** `GET /api/fights/poll` (the polling protocol BOT-02's unified prompt documents) was pre-existing-broken — a `GET /:id` dynamic route registered earlier in `server/src/routes/fights.ts` shadowed the later-registered static `GET /poll` route, so any polling bot's poll request was matched as a fight-id lookup for id `"poll"` and always returned `404 {"error":"Fight not found."}`. Reproduced independently on a throwaway container with a fresh DB (not an artifact of the arena's seeded data) before fixing. Fixed by moving the `/poll` and `/poll/respond` route registrations above `/:id` in the router. This was necessary to meet this plan's own acceptance criterion (bot auth via `GET /api/fights/poll` against the public arena) and to make BOT-02's unified prompt's polling-mode documentation actually true. ## Rolling the image tag The tag is kept in exactly one place — `docker-compose.arena.yml`'s `image:` line. To roll (e.g. plan 09-05's 1.2.0 build): ```bash # 1. Edit docker-compose.arena.yml: image: localhost:3000/lfg2025/botfights:1.2.0 # 2. Copy to the host and redeploy: scp docker-compose.arena.yml debian@146.59.87.168:/opt/botfights-arena/docker-compose.yml ssh debian@146.59.87.168 'cd /opt/botfights-arena && docker compose pull && docker compose up -d' ``` The named volume (and therefore all arena data) is untouched by an image roll — only `docker compose down -v` (never run this without intent) removes it. ## Tearing it down ```bash ssh debian@146.59.87.168 ' cd /opt/botfights-arena docker compose down # stops + removes the container; volume persists # docker compose down -v # ALSO deletes the botfights-arena-data volume — destructive, confirm first # rm -rf /opt/botfights-arena # only after confirming the volume is gone/backed up ' ``` ## Ports already bound on VPS2 (verified 2026-07-30, re-check with `sudo ss -tlnp` before reusing) 22, 80, 81, 443, 2100, 2101, 2222, 3000, 3009, 5355, 7788, 8000, 8092, 8123, 8443, 8444, 9443, and now **9100** (this deployment).