A throwaway 1.2.0 container in proxy mode (no volume, ARENA_UPSTREAM_URL pointed at the canonical arena) demonstrated: a fighter registered directly against the arena is visible through it (D1), a fighter registered through it is visible on the arena directly (reverse), SSE streams incrementally through it, /api/health answers locally during an arena outage, and /api/bots degrades cleanly during that outage. Also documents a deviation: the canonical URL now sits behind nginx-proxy-manager (per the mid-phase DNS/TLS decision superseding this plan's original plain-HTTP wording), so a stopped arena's 502 through that URL is NPM's own HTML page rather than arena-proxy.ts's JSON body — the underlying JSON degradation contract is separately confirmed live against the raw fallback port, which has no intermediary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
20 KiB
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_URLunset (standalone mode) that other nodes point at. - Each node picks its own community by setting
ARENA_UPSTREAM_URLin 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 hardcodes146.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_URLpoints. - How to host your own arena: deploy this exact
docker-compose.arena.ymlpattern (or even the node's normaldocker-compose.yml) anywhere reachable, leaveARENA_UPSTREAM_URLunset on it, generate your ownJWT_SECRET, and point whichever nodes you want in your community atARENA_UPSTREAM_URL=http://<your-host>:<port>. 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/*toARENA_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:
- Add an A record, e.g.
arena.archipelago-foundation.org→146.59.87.168(GoDaddyns29/ns30.domaincontrol.com, no wildcard — this needs its own record). - In nginx-proxy-manager (
https://146.59.87.168:81, adminlfg2025@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 existingdemo./source./fips.hosts.
- Domain:
- Change only the value every node reads:
ARENA_UPSTREAM_URLinapps/botfights/manifest.yml(archy repo) fromhttp://146.59.87.168:9100tohttps://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=1was 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). - Optionally keep
:9100open as a fallback/legacy path, or firewall it down to only127.0.0.1once 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:
# 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):
# 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:
docker compose stopon the arena (avoid the app writing to the volume mid-copy).scpthe exported.dbfile to VPS2, then as root:cpit into the named volume's host mountpoint asbotfights.db, removing any stray-wal/-shmfiles from the fresh-start container run.chownthe file to uid/gid999— the container's non-rootbotfightssystem user (verify withdocker inspect botfights-arena --format '{{.Config.User}}'and the uiduseradd --systemassigned it, since docker on this host does not use userns-remap — the host uid IS the container uid).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)
# 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:
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 <token from Gitea admin, see infra memory note>
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):
# 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
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).
1.2.0 public-contract verification (plan 09-05, 2026-07-31)
All checks below ran against https://botfights.archipelago-foundation.org
(never 127.0.0.1/the raw port) after docker compose pull && up -d recreated
the container on the botfights:1.2.0 tag (post-poll-fix build):
| Check | Result |
|---|---|
GET /api/health |
{"status":"ok","name":"botfights"} |
GET /api/docs/prompt |
200, text/markdown, arena URL substituted 9×, zero leftover {{ARENA_URL}} tokens |
GET /api/auth/me (no token) |
401 |
POST /api/bots (anonymous, from off-host) |
200, id+secret issued; bot immediately visible in GET /api/bots |
GET /api/fights/poll (bot auth via Authorization: Bot id:secret) |
200 {"pending":false} — see the GET /:id route-order fix above; this was 404 before it |
POST /api/queue/join/<botId> → poll again |
matched into a real fight within seconds; poll returned the live challenge payload |
GET /api/fights/<id>/stream (SSE) |
Incremental delivery confirmed: spectator_count/ping events at connection open, a second ping ~15s later, then round_end/round_start/poll_challenge in a fresh cluster ~4-5s after that — spread over a live 25s capture window, not buffered until stream close |
JWT_SECRET survived the roll |
/opt/botfights-arena/.env mtime predates this session's image rolls (unchanged); container's JWT_SECRET env still sources ${JWT_SECRET} from that same file, not a freshly generated value |
| Data integrity | GET /api/bots → 101 (100 original + 1 test bot from an earlier verification pass), ?type=classic → 15, unchanged/grown from the pre-roll 100+15 |
Test bots left in the arena, clearly named per this plan's own naming
convention (no bot-deletion API exists in this codebase to remove them
cleanly): wavetest2, wavetest3 — both anonymous, harmless, real
fighters; consistent with the arena's existing FIGHT_LOOP_ENABLED=true
mock-bot background activity. wavetest3 fought one live match as part of
verifying the SSE stream above.
Verified cross-instance behaviour (plan 09-05 Task 3, 2026-07-31)
A throwaway botfights:1.2.0 container (botfights-proxytest, port 9101,
no volume mount — nothing worth reading locally) ran on archi-dev-box with
ARENA_UPSTREAM_URL=https://botfights.archipelago-foundation.org, alongside
(never touching) the installed botfights app on port 9100 (image 1.1.0).
| Check | Result |
|---|---|
| Proxy instance has no local data of its own | Startup still seeds a local 100+15 mock-bot DB (unrelated background code path that runs regardless of ARENA_UPSTREAM_URL) — but every /api/* request is intercepted by arena-proxy before it ever reaches a local route handler, so that local data is never exposed through the API |
GET /api/bots via the proxy instance |
Returned 103 bots, including wavetest2 and wavetest3 — both registered directly against the arena in Task 2, never touching this instance. This is the D1 proof: a fighter registered on one host is visible through a different instance that never stored it. |
| Reverse direction: register via the proxy instance | POST http://127.0.0.1:9101/api/bots {"name":"wavetest4"} succeeded, and wavetest4 was immediately visible in GET https://botfights.archipelago-foundation.org/api/bots directly |
| SSE through the proxy instance | wavetest3 matched into a real fight; GET http://127.0.0.1:9101/api/fights/<id>/stream delivered spectator_count/ping at connection open and a second ping ~15s later — incremental, not buffered |
/api/health bypass during a deliberate arena outage |
docker compose stop on the VPS2 arena (seconds); GET http://127.0.0.1:9101/api/health still returned 200 {"status":"ok",...} throughout — confirmed answered locally per arena-proxy.ts's LOCAL_BYPASS_PATHS, never forwarded |
/api/bots during the same outage, via the canonical HTTPS URL (fronted by nginx-proxy-manager since 2026-07-30) |
502, but the body was NPM's own HTML error page, not the app's JSON — because NPM itself answers with a gateway-level 502 before the request ever reaches the stopped container; fetch() inside arena-proxy.ts succeeds against NPM and passes its response through verbatim. This supersedes the plan's original acceptance wording (written when the arena was still plain-HTTP/no-NPM); NPM 502ing here is expected, correct behavior for a proxy in front of a stopped upstream. |
/api/bots during a second, separate short outage, via the raw fallback port (http://146.59.87.168:9100, no NPM in front) |
502 {"error":"Arena unreachable."} — arena-proxy.ts's own JSON degradation path (already unit-tested in 09-01), confirmed live against a real stopped upstream with no intermediary |
| Recovery | docker compose start on VPS2 both times; arena healthy again within seconds; the proxy instance's own subsequent requests succeeded immediately, no restart needed on the node side |
| Installed app isolation | podman ps --filter name=botfights showed the installed botfights app (port 9100, image :1.1.0) with its original container id and uptime, unaffected throughout; no botfights-proxytest* container remains after cleanup |
Test bots registered during this task, left in the arena (same rationale
as Task 2 — clearly named, no delete API exists): wavetest4.