One canonical BotFights arena runs on VPS2 (146.59.87.168) in standalone mode — it owns the only real match/fighter database (D-03/BOT-03)
The arena answers /api/health on the VPS2 host before any DNS or TLS work begins, so a later public failure is unambiguously a routing problem and not an app problem
The arena's JWT signing secret is generated on the VPS2 host, stored 0600 outside git, and never appears in a tracked file or in a log
The arena rate-limits on the direct socket peer IP (no reverse proxy in front — TRUSTED_PROXY deliberately unset), so per-IP limits see real client IPs
Lightning/cashu payment features are left unconfigured on the public arena — they are explicitly out of this phase's scope
How to redeploy this arena from scratch is written down in the repo, because NPM's routing and the host .env are not git-tracked artifacts
The runbook: host paths, port, NPM proxy-host values, DNS record, secret handling, rollback
146.59.87.168
from
to
via
pattern
public internet (node instances + cloud bots)
the canonical arena container on VPS2
direct http://146.59.87.168:9100 (user-chosen: no DNS/NPM/TLS; port 9100 published by docker and open through the host firewall)
9100
Stand up the one canonical BotFights arena on VPS2 at `http://146.59.87.168:9100` —
the shared endpoint every node's instance will proxy to (D-03/BOT-03). (User decision 2026-07-30:
no DNS/TLS; plain HTTP on the raw port, TLS is a later env-only upgrade.)
Decision IDs map to 09-CONTEXT.mdLocked Decisions: D-01 = BOT-01 signer login,
D-02 = BOT-02 unified prompt, D-03 = BOT-03 shared public match endpoint on VPS2,
D-04 = BOT-04 registry/catalog.
Purpose: this is the longest-latency item on the demo path because it needs a DNS record at the
registrar and a certificate issuance, neither of which Claude can perform. Starting it in wave 1,
in parallel with the app work, is deliberate — if DNS stalls, the fallback (plain HTTP on the public
IP and port) is still enough for a cloud bot, and the plan says so explicitly rather than leaving
the demo to discover it.
Output: a running canonical arena on VPS2, a committed compose file and runbook, a public hostname
with a valid certificate, and a recorded decision about what data the arena starts with.
Repos/hosts: compose + runbook are committed in /home/archipelago/Projects/botfight
(git push origin main); the arena itself runs on VPS2 (debian@146.59.87.168, key
~/.ssh/id_ed25519_vps168, docker not podman, passwordless sudo). VPS2 is host infrastructure —
the rootless-podman invariant applies to Archipelago nodes, not to this host.
container nginx-proxy-manager-app-1, compose dir /home/debian/nginx-proxy-manager, data /home/debian/nginx-proxy-manager/data, admin UI on :81, admin user lfg2025@proton.me
archipelago-foundation.org on ns29/ns30.domaincontrol.com (GoDaddy). demo./source./fips. resolve to 146.59.87.168. No wildcard — a new subdomain needs a new A record
Registry
146.59.87.168:3000/lfg2025 (Gitea on the same host, so the arena can pull from localhost:3000)
Task 1: Decide the arena hostname and what data the public arena starts with — RESOLVED: no hostname (http://146.59.87.168:9100 direct), full database copy
What hostname does the canonical arena use, and what data does it start with?
Two choices must be made before the arena is deployed, because both are baked into the compose
file, the NPM proxy host, the DNS record, and (later) the app manifest every node reads.
(1) **Hostname.** Planning recommends `arena.archipelago-foundation.org` — it matches the
established `<name>.archipelago-foundation.org` pattern already used by demo/source/fips/
companion, and it is what plan 09-06 will write into `apps/botfights/manifest.yml` as
`ARENA_UPSTREAM_URL`. Changing it later means re-signing and re-publishing the catalog.
(2) **Starting data.** archi-dev-box's existing BotFights instance holds 115 registered bots and
102,440 fights (351 MB). Once nodes switch to proxy mode, that local database stops being read —
it is preserved on disk, but those 115 fighters vanish from the UI unless the arena starts with
them.
Option A — fresh empty arena. Cleanest and most private; the demo starts with an empty
leaderboard and only bots registered from now on (including the cloud openclaw bot).
Option B — seed the `bots` table only (recommended). Copies the 115 fighter rows (name, ELO,
W/L, avatar, `secret_hash`, `public_key`) into a fresh arena DB and drops the 102k-row fight
history, so the arena is small, the leaderboard looks alive for the demo, and every bot script
that already holds credentials keeps working. It does publish those bot names and nostr pubkeys
on a public server.
Option C — copy the whole 351 MB database, fight history included. Most continuity, largest
surface, and carries `payments`/`bets` tables (both currently empty) onto a public host.
Reversibility: the arena DB is brand new and can be re-seeded or wiped until real users register
against it — so this is reversible today and progressively less so after the demo.
arena.archipelago-foundation.org + fresh empty database
Nothing private leaves archi-dev-box; smallest attack surface; fastest deploy
Empty leaderboard on demo day; existing 115 bots are invisible until re-registered
arena.archipelago-foundation.org + seed the bots table only (recommended)
Demo shows a populated roster; existing bot credentials keep working; arena stays small; no fight/payment history exported
115 bot names and their nostr pubkeys become publicly visible on the internet
arena.archipelago-foundation.org + full database copy
Complete continuity including fight history and stats
351 MB transfer; exports fight history and empty-but-present payment tables to a public host
A different hostname (state it) with one of the data options above
Whatever naming the domain owner prefers
Must be decided now — it is written into the signed catalog in plan 09-06
RESOLVED by user 2026-07-30 via AskUserQuestion: (1) hostname — "can we do it a different way without needing to do that?" → no DNS record; the canonical arena URL is `http://146.59.87.168:9100` directly (server-side proxy hop, no mixed content; TLS subdomain is a later env-only upgrade). (2) data — "Full copy" → copy the entire 351 MB botfights.db from archi-dev-box (read-only export; source file untouched). Do NOT re-ask.
Task 2: Deploy the canonical arena on VPS2 and prove it healthy on the host
The hostname and data-seed decision from Task 1 has been given, and `ssh debian@146.59.87.168` succeeds with the pinned key.
A single new docker compose project in its own directory on
VPS2 with its own named volume; `docker compose down -v` plus deleting the directory removes it
with no effect on any other service on that host.
/home/archipelago/Projects/botfight/docker-compose.arena.yml, /home/archipelago/Projects/botfight/docs/arena-deployment.md
- `/home/archipelago/Projects/botfight/docker-compose.yml` — the existing service definition to
derive from: container name, port 9100, named volume `botfights-data` mounted at
`/app/server/data`, and the full env list including the payment vars that must NOT be set here.
- `/home/archipelago/Projects/botfight/Dockerfile` — confirms `PORT=9100`, the non-root
`botfights` user, and the built-in HEALTHCHECK hitting `/api/health`.
- `/home/archipelago/Projects/botfight/server/src/middleware/jwt.ts` lines 1-8 — the module
throws at import when `JWT_SECRET` is unset and `NODE_ENV=production`, which is why the host
`.env` is mandatory, not optional.
- `/home/archipelago/Projects/botfight/server/src/middleware/rate-limit.ts` lines 36-52 — why
`TRUSTED_PROXY` must be set on this instance specifically (it sits behind NPM).
- `.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md` "Open Questions" #2 — payments
env vars deliberately left unset.
Add `docker-compose.arena.yml` to the botfight repo — the canonical-arena counterpart of the
existing dev compose file. It must:
- use `image: localhost:3000/lfg2025/botfights:` (the Gitea registry runs on this same
host, so no cross-host pull) with **no** `build:` section — the arena runs published images
only, so what it runs is exactly what nodes run;
- start at the currently published tag `1.1.0` (plan 09-05 rolls it to 1.2.0 once that image is
built and pushed) and keep the tag in one place so the roll is a one-line edit;
- bind `9100:9100`, `restart: unless-stopped`, container name `botfights-arena`, named volume
`botfights-arena-data:/app/server/data`;
- set `NODE_ENV=production`, `PORT=9100`, `FIGHT_LOOP_ENABLED=true`,
`PUBLIC_ARENA_URL=http://146.59.87.168:9100`, `JWT_SECRET=${JWT_SECRET}` and
OMIT `TRUSTED_PROXY` (no NPM in front — clients hit :9100 directly, so the arena must
use the socket peer IP for rate limiting, not forwarded headers), and
`BOTFIGHTS_CREATOR_PUBKEYS=${BOTFIGHTS_CREATOR_PUBKEYS:-da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d21c3fa39}`;
- deliberately omit `ARENA_UPSTREAM_URL` (this instance IS the upstream) and omit every
`BOTFIGHTS_WALLET_ENCRYPTION_KEY` / `BOTFIGHTS_NWC_URL` / `BOTFIGHTS_CASHU_MINT_URL` /
`BOTFIGHTS_DEV_PAYOUT_LNADDRESS` variable, with a comment saying payments are out of scope for
this deployment;
- contain no secret values whatsoever — `JWT_SECRET` comes from a host `.env` file.
On VPS2 (`ssh debian@146.59.87.168`):
1. `sudo ss -tlnp` and confirm 9100 is still free before binding it.
2. Create `/opt/botfights-arena/`, copy `docker-compose.arena.yml` there as
`docker-compose.yml`, and write `/opt/botfights-arena/.env` containing
`JWT_SECRET=$(openssl rand -hex 32)` with mode 0600. Never print that value into the
transcript, a log, or any file under a git repo.
3. `docker compose up -d`, wait for the container's healthcheck, then verify on the host:
`curl -fsS http://127.0.0.1:9100/api/health` returns `{"status":"ok","name":"botfights"}`.
4. Apply the Task 1 data decision. For the seed options, export from archi-dev-box with a
read-only sqlite connection (the node's app is live — never write to its database), copy the
result to VPS2 over ssh, stop the arena container, place the file as the arena volume's
`botfights.db`, restart, and verify the bot count through `GET /api/bots`. For option A do
nothing beyond letting the app create its own database.
5. Confirm the arena is reachable from off-host on the raw port before any DNS exists:
from archi-dev-box, `curl -fsS http://146.59.87.168:9100/api/health`. If that fails, an OVH
or ufw firewall rule is blocking 9100 — record it in the runbook and note that the plain-HTTP
contingency in Task 3 depends on it.
Write `docs/arena-deployment.md` in the botfight repo covering: the host and directory, the
port, how the secret is generated and where it lives, the exact NPM proxy-host field values, the
DNS record, the seed decision that was taken, how to roll the image tag, and how to tear it
down. NPM routing and the host `.env` are not git-tracked — this file is the only record.
ssh -o BatchMode=yes debian@146.59.87.168 'curl -fsS http://127.0.0.1:9100/api/health' | grep -q '"status":"ok"' && curl -fsS --max-time 10 http://146.59.87.168:9100/api/health | grep -q '"status":"ok"'
- `ssh debian@146.59.87.168 'curl -fsS http://127.0.0.1:9100/api/health'` returns the ok payload.
- `ssh debian@146.59.87.168 'sudo docker ps --filter name=botfights-arena --format "{{.Status}}"'` shows `Up` and healthy.
- `ssh debian@146.59.87.168 'stat -c %a /opt/botfights-arena/.env'` prints `600`.
- `ssh debian@146.59.87.168 'sudo docker inspect botfights-arena --format "{{json .Config.Env}}"'` contains no `ARENA_UPSTREAM_URL` entry and no `TRUSTED_PROXY` entry (direct exposure — rate limit on socket peer IP).
- `grep -c 'BOTFIGHTS_WALLET_ENCRYPTION_KEY\|BOTFIGHTS_NWC_URL\|BOTFIGHTS_CASHU_MINT_URL' /home/archipelago/Projects/botfight/docker-compose.arena.yml` counts only commented lines, and no such variable is present in the container's env output.
- `git -C /home/archipelago/Projects/botfight grep -c 'JWT_SECRET=' -- docker-compose.arena.yml` shows only the `${JWT_SECRET}` indirection, never a literal value.
- `test -f /home/archipelago/Projects/botfight/docs/arena-deployment.md` and it contains the chosen hostname, `9100`, and the NPM field values.
- For seed options B/C: `curl -fsS http://146.59.87.168:9100/api/bots` returns the expected number of fighters; the source database on archi-dev-box is unmodified (`mtime` unchanged).
A canonical arena container is running and healthy on VPS2 with a host-generated secret, correct proxy-trust settings, no payment configuration, and the agreed starting data.
Task 3: Prove the arena is internet-reachable at http://146.59.87.168:9100 (no DNS/TLS — user decision 2026-07-30)
Task 2 shows the arena healthy on-host.
Firewall rule (if one is needed) is a single ufw/iptables/OVH
entry that can be removed; nothing else changes.
The user chose to skip DNS + NPM + Let's Encrypt entirely: the canonical arena URL IS
`http://146.59.87.168:9100`. This works because the node→arena hop is a server-side proxy
(no browser mixed-content) and cloud bots speak server-to-server. TLS can be added later by
fronting the same port with an NPM host and changing only `ARENA_UPSTREAM_URL` — no code change.
Verify reachability from OFF the VPS: from archi-dev-box, `curl -fsS --max-time 10
http://146.59.87.168:9100/api/health`. If it fails, check and fix host-level firewalling on
VPS2 (`sudo ufw status`, iptables, and note OVH network-level firewall may need the user).
Record the final reachable URL + any firewall change in `docs/arena-deployment.md` and in
09-04-SUMMARY.md — plans 09-05/09-06/09-07 read the arena URL from there.
TLS termination; the only intended public entrance
public internet → 146.59.87.168:9100
The raw container port, reachable while it is bound to all interfaces
VPS2 host filesystem → arena container
/opt/botfights-arena/.env carries the JWT signing key
arena container → its SQLite volume
The single source of truth for every node's fighters
STRIDE Threat Register
Threat ID
Category
Component
Severity
Disposition
Mitigation Plan
T-09-16
Information disclosure
credentials over plain HTTP on the raw port
high
accept (user decision 2026-07-30)
User explicitly chose plain HTTP on :9100 over DNS/TLS setup; recorded in runbook with the env-only TLS upgrade path for later
T-09-17
Information disclosure
JWT_SECRET leaking into git, logs or docker inspect transcripts
high
mitigate
Generated on the host into a 0600 .env outside any repo; the compose file only carries ${JWT_SECRET}; the value is never echoed
T-09-18
Spoofing
forged X-Forwarded-For from a direct caller to :9100 bypassing per-IP limits
medium
accept
TRUSTED_PROXY is required for the arena to honour those headers and NPM overwrites X-Real-IP for traffic through 443; a direct caller can already choose its own source IP cheaply, so this adds no meaningful capability
T-09-19
Denial of service
anonymous bot-registration flooding the public arena
medium
mitigate
Existing rateLimit(3600_000, 5) on POST /api/bots, now meaningful per real client IP thanks to TRUSTED_PROXY
T-09-20
Information disclosure
seeded bot rows publishing nostr pubkeys to the internet
medium
transfer
Surfaced as an explicit decision (Task 1) with the fresh-database option available; the user owns this choice
T-09-21
Tampering
writing to archi-dev-box's live database while exporting a seed
high
mitigate
Export uses a read-only sqlite URI; the acceptance criteria assert the source file is unmodified
T-09-22
Denial of service
binding a port already in use on a shared host
low
mitigate
9100 verified free during planning and re-checked before binding
</threat_model>
- `ssh debian@146.59.87.168 'curl -fsS http://127.0.0.1:9100/api/health'` — ok payload.
- `curl -fsS http://146.59.87.168:9100/api/health` from archi-dev-box — ok payload (or a recorded
firewall finding).
- `curl -fsS http://146.59.87.168:9100/api/health` returns 200 from off-host — this is asserted
again as plan 09-05 Task 1's precondition, which gates the rest of the demo path.
- Supports `09-VALIDATION.md`'s manual-only item "Cross-node fighter visibility" by providing the
shared endpoint that item depends on.
<success_criteria>
One canonical arena container runs on VPS2 with a host-generated secret and no payment config.
Its compose definition and full runbook are committed to the botfight repo.
The full-copy data seed is live (115 bots visible via /api/bots) and the source DB on archi-dev-box is untouched.
The arena is internet-reachable at http://146.59.87.168:9100 (user-accepted plain HTTP; TLS is a later env-only upgrade).
</success_criteria>
Create `.planning/phases/09-botfights-platform-upgrade/09-04-SUMMARY.md` when done, recording the
chosen hostname, the seed decision, and whether TLS or the fallback is in effect — plans 09-05,
09-06 and 09-07 all read that hostname from here.
Commit the botfight changes with `git add` by explicit path and `git push origin main`.
Commit the SUMMARY in archy and `git push gitea-ai main`.