Levy Street production is deployed via Ansible, not this document: the
eastbrook_gamerole in the internalansible-scriptsrepo runs the stack onidyllic-games-prodbehind nginx + certbot at https://worldofclaudecraft.com. Re-runningansible-playbook playbooks/setup_server.yml -e target_host=idyllic-games-prodpulls and redeploys. The guide below is the generic, standalone path.
One EC2 instance runs everything: the game server, Postgres, MediaWiki, and Caddy
(TLS reverse proxy). Sized for a small population — a t4g.small
(~$14/month all-in) is comfortable for a handful of concurrent players.
The standalone first-boot script clones
https://github.com/levy-street/world-of-claudecraft.git anonymously. If you
are deploying a private fork instead, use a deploy key or another secret
manager-specific flow; do not paste long-lived personal access tokens into EC2
user data.
In the EC2 console:
| Setting | Value |
|---|---|
| AMI | Ubuntu Server 24.04 LTS (arm64) |
| Instance type | t4g.small (2 vCPU Graviton, 2 GB) |
| Storage | 20 GB gp3 |
| Security group | Inbound: 22 (your IP only), 80, 443 — nothing else |
| User data | Paste deploy/user-data.sh with DOMAIN filled in |
Leave DOMAIN="" if you want to test by IP first over plain HTTP —
you can set the domain later (step 4).
Allocate an Elastic IP and associate it with the instance so the address survives restarts.
The game server and Postgres bind to loopback only (127.0.0.1:8787 /
127.0.0.1:5433); Caddy is the sole public entrance, so the security
group above is the whole exposure story.
First boot takes a few minutes (Docker image build). Watch it with:
ssh ubuntu@<elastic-ip> sudo tail -f /var/log/eastbrook-setup.logCreate an A record for your domain (e.g. play.example.com) pointing
at the Elastic IP. In Route 53: Hosted zone → Create record → A →
the Elastic IP.
ssh ubuntu@<elastic-ip>
echo 'play.example.com {
route /wiki* {
reverse_proxy localhost:8080
}
reverse_proxy localhost:8787
encode gzip
}' | sudo tee /etc/caddy/Caddyfile
sudo systemctl reload caddyCaddy fetches and renews the Let's Encrypt certificate automatically;
WebSockets are proxied with no extra config, and the client auto-selects
wss:// on https pages. Open https://play.example.com and you're live.
ssh ubuntu@<elastic-ip>
cd /opt/eastbrook
sudo git pull
sudo docker compose up -d --buildPlayers online during the restart are disconnected for a few seconds and can log straight back in; the server saves all characters on shutdown.
A nightly pg_dump runs at 03:15 UTC via /etc/cron.d/eastbrook-backup,
writing gzipped dumps to /var/backups/eastbrook/ and keeping 14 days.
Restore (stack must be up):
gunzip -c /var/backups/eastbrook/eastbrook-2026-06-10.sql.gz \
| sudo docker exec -i eastbrook-db psql -U eastbrook eastbrookFor off-box safety, sync the directory to S3 occasionally:
aws s3 sync /var/backups/eastbrook s3://your-bucket/eastbrook/.
- Secrets: the Postgres password is generated at first boot into
/opt/eastbrook/.env(mode 600, gitignored). Nothing else to manage. - Username bans: set
USERNAME_BANLIST_FILE=/opt/eastbrook/username-banlist.txtto load blocked username terms from a private newline- or comma-separated file.USERNAME_BANLISTcan also provide a comma-separated inline list. - Chat filter: the word lists are now managed live from the admin
dashboard (Chat Filter tab), stored in the database and seeded with sensible
defaults on first boot. Two tiers: soft words are masked client-side with
****(players can toggle the filter off in Options), and hard words (slurs) are blocked server-side and escalate from a warning to account-wide timed mutes (durations editable in the same tab).CHAT_CENSOR_LIST/CHAT_CENSOR_FILEare still read once, on the first boot of a fresh database, to seed the soft list — after that they are ignored and the dashboard is authoritative. - Realms (horizontal scaling): each server process serves one realm,
set by
REALM_NAME(defaultClaudemoon). To add a realm, run another process against the sameDATABASE_URLwith a differentREALM_NAMEandPORT(e.g. behind its own vhost or compose service). Characters, friends, guilds, and presence are realm-scoped, so the worlds are fully isolated — players on different realms can't see, whisper, friend, or guild each other. Concurrent boots serialize their schema setup behind a Postgres advisory lock, so starting several at once is safe. Character and guild names remain globally unique across realms. - Bot gate (Cloudflare Turnstile): login and registration can be gated by
Turnstile so headless clients (the aiohttp/websockets bot wave) can't create or
sign into accounts. It is off until configured: both halves must be set or
the gate silently does nothing:
TURNSTILE_SECRET(server runtime, secret): enables server-side verification.VITE_TURNSTILE_SITEKEY(public): renders the widget. This is read by the client and inlined atnpm run buildtime, so it must be present when the image/bundle is built, not just at runtime. Use a separate Turnstile widget per environment (dev vs prod). If the origin's nginx (in theansible-scriptsrepo) sets a Content-Security-Policy, it must allowscript-src/frame-src https://challenges.cloudflare.comor the widget won't load.
- Wallet linking: the wallet UI uses injected Solana browser wallets and no
third-party wallet-connect project id. $WOC balance reads are server-side
only: set
SOLANA_RPC_URLto a production Solana RPC endpoint and leave it unprefixed so API keys are not bundled into the client.WOC_MINTdefaults to the canonical token mint and should only be overridden if that mint changes. SetPUBLIC_ORIGINin single-realm production so shared player-card pages emit stable absolute Open Graph URLs. - Never set
ALLOW_DEV_COMMANDS=1in production: it enables the level/teleport cheats used by the test bots. - Bot detector (implementation): the open-source tree ships with a no-op stub
(
server/bot_detector/stub.ts). Detection hooks are wired in, but they observe nothing and never act. To bundle the real behavioral detector, clone the privatebot_detectorrepo intoprivate/bot_detectorbeforenpm run build(ornpm run build:server). That directory is not part of the public checkout. At build time, confirm which implementation was picked:[build:server] bot detector: stub (no-op)vs… bot detector: private. - Anti-bot runtime knobs:
MAX_WS_PER_IP_HARD(default20) caps simultaneous WebSocket connections per source IP; extra connections are refused at the handshake.ANTIBOT_ENFORCE=1lets the detector act on its findings (e.g. kick); when unset, detection is observe-only. With the no-op stub, enforcement has no effect regardless of this flag. - Logs:
sudo docker compose -f /opt/eastbrook/docker-compose.yml logs -f game. - If the instance ever feels tight, stop → change instance type → start. Everything lives in Docker plus one EBS volume, so nothing else changes.
The admin dashboard (account/character/session metrics, live players, server health) is served by the same game server process:
- Production: point
admin.worldofclaudecraft.comat the instance (A record) and add a server block for it in the nginx config in the internalansible-scriptsrepo, proxying to the same game port as the main site. The Node server serves the dashboard for any hostname starting withadmin.. - Standalone/Caddy: set
ADMIN_DOMAINindeploy/user-data.sh(or add the extra site block to/etc/caddy/Caddyfileby hand). - Local dev: open
http://localhost:8787/admin(or/adminundernpm run dev).
Access requires signing in with a game account that has the is_admin
flag. The hostname only selects which HTML shell is served — every
/admin/api/* call is checked against the account flag.
Grant the first admin:
# locally
npm run admin:grant -- <username>
# on the box (the runtime image only ships bundled code, so use psql)
sudo docker exec eastbrook-db psql -U eastbrook eastbrook \
-c "UPDATE accounts SET is_admin = TRUE WHERE username = '<username>';"Revoke with npm run admin:grant -- <username> --revoke (or set the
flag to FALSE in SQL).