services: postgres: image: postgres:16-alpine container_name: eastbrook-db restart: unless-stopped environment: POSTGRES_USER: eastbrook POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env. See .env.example.} POSTGRES_DB: eastbrook ports: - "127.0.0.1:5433:5432" volumes: - eastbrook_pgdata:/var/lib/postgresql/data # pg_isready is a trivial ping, so polling it often costs nothing, but a permanent # 1Hz steady-state probe is unwanted overhead on the production stack this file also # deploys (see DEPLOY.md). start_period + start_interval give the fast 1s cadence # ONLY during a cold boot (failures inside start_period don't spend the retry # budget at all), while interval/timeout/retries return to the same steady-state # values used before this change, keeping the ~50s outer ceiling for a genuinely # cold initdb intact. -h pins the probe to the TCP listener so it cannot read # healthy off initdb's transient unix-socket-only bootstrap server. healthcheck: test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U eastbrook -d eastbrook"] interval: 5s timeout: 3s retries: 10 start_period: 60s start_interval: 1s # The full game server (client + API + world). `docker compose up -d` # then open http://localhost:8787 — or put a TLS reverse proxy in front # for remote hosting (see README). game: image: eastbrook-game:${EASTBROOK_IMAGE_TAG:-local} build: context: . args: # Build-time public client config inlined into the bundle (see .env.example). VITE_TURNSTILE_SITEKEY: ${VITE_TURNSTILE_SITEKEY:-} VITE_REOWN_PROJECT_ID: ${VITE_REOWN_PROJECT_ID:-} VITE_WALLET_DISABLED: ${VITE_WALLET_DISABLED:-} container_name: eastbrook-game restart: unless-stopped depends_on: postgres: condition: service_healthy environment: DATABASE_URL: postgres://eastbrook:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env. See .env.example.}@postgres:5432/eastbrook PORT: "8787" # Runtime: node flags for the game process, chiefly the V8 heap ceiling # (--max-old-space-size). Node sizes its default old-space from TOTAL SYSTEM # memory, not from the container's cgroup limit, so on a box larger than # mem_limit an unset heap flag lets V8 grow past the limit and get OOM-killed # mid-tick by the kernel. Sizing the heap UNDER mem_limit means an overrun # kills the container (which docker then restarts) instead of the host OOM # killer wedging the box. Empty (unset) keeps node's own defaults: the # recommended production value lives in .env.example, never baked in here. NODE_OPTIONS: ${NODE_OPTIONS:-} # Runtime: the realm player admission cap. A fresh WebSocket join is refused # once the realm holds this many sessions (server/ws_auth.ts); /api/status # advertises the value so the client realm list can show Full honestly. Empty # (unset) yields the built-in default of 5000; an explicit 0 disables the cap. MAX_PLAYERS_PER_REALM: ${MAX_PLAYERS_PER_REALM:-} # Runtime: Postgres clients this process keeps in its pool (server/db.ts). # Empty (unset) yields the built-in default of 10; accepted range is 1 to # 97 and anything else falls back to the default with an error at boot. DB_POOL_MAX_CLIENTS: ${DB_POOL_MAX_CLIENTS:-} # Community test realms can opt in to a ready-made level-20 roster for # every newly created account. The production-safe default is disabled. PROVISION_TEST_ACCOUNTS: ${PROVISION_TEST_ACCOUNTS:-0} # PBE only: boost registrations to a BiS roster and top existing # characters up to the current BiS kit at world join (server/pbe_boost.ts). # NEVER set in production. The production-safe default is disabled. PBE_BOOST_ACCOUNTS: ${PBE_BOOST_ACCOUNTS:-} # Target the game server 302-redirects /wiki to when hit directly (not via the # reverse proxy). Public origin in prod; localhost fallback for local dev. WIKI_URL: ${WIKI_URL:-http://localhost:8080/wiki/index.php/Main_Page} # Runtime public origin for server-generated absolute URLs such as player-card # Open Graph images. Set per environment, for example dev vs production. PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:-} USERNAME_BANLIST: ${USERNAME_BANLIST:-} USERNAME_BANLIST_FILE: ${USERNAME_BANLIST_FILE:-} CHAT_CENSOR_LIST: ${CHAT_CENSOR_LIST:-} CHAT_CENSOR_FILE: ${CHAT_CENSOR_FILE:-} # Runtime: server-side Turnstile verification (see .env.example). Empty → gate off. TURNSTILE_SECRET: ${TURNSTILE_SECRET:-} # Runtime: native app attestation for iOS DeviceCheck and Google Play Integrity. # Secrets are read from .env on the host and passed through to the container. NATIVE_ATTESTATION_REQUIRED: ${NATIVE_ATTESTATION_REQUIRED:-} GOOGLE_PLAY_INTEGRITY_SERVICE_ACCOUNT_JSON: ${GOOGLE_PLAY_INTEGRITY_SERVICE_ACCOUNT_JSON:-} GOOGLE_PLAY_INTEGRITY_SIGNING_PEM: ${GOOGLE_PLAY_INTEGRITY_SIGNING_PEM:-} GOOGLE_PLAY_INTEGRITY_PACKAGE_NAME: ${GOOGLE_PLAY_INTEGRITY_PACKAGE_NAME:-com.worldofclaudecraft} GOOGLE_PLAY_INTEGRITY_CERT_DIGESTS: ${GOOGLE_PLAY_INTEGRITY_CERT_DIGESTS:-} GOOGLE_PLAY_INTEGRITY_DEVICE_VERDICT: ${GOOGLE_PLAY_INTEGRITY_DEVICE_VERDICT:-MEETS_DEVICE_INTEGRITY} # Solana Store (Seeker) artifact allowlist: fail-closed while unset, so the # Solana native-auth arm and Seeker claim/spin stay off until configured. SEEKER_SOLANA_INTEGRITY_PACKAGE_NAME: ${SEEKER_SOLANA_INTEGRITY_PACKAGE_NAME:-} SEEKER_SOLANA_INTEGRITY_CERT_DIGESTS: ${SEEKER_SOLANA_INTEGRITY_CERT_DIGESTS:-} SEEKER_SOLANA_INTEGRITY_DEVICE_VERDICT: ${SEEKER_SOLANA_INTEGRITY_DEVICE_VERDICT:-MEETS_DEVICE_INTEGRITY} APPLE_TEAM_ID: ${APPLE_TEAM_ID:-} APPLE_BUNDLE_ID: ${APPLE_BUNDLE_ID:-com.worldofclaudecraft} APPLE_DEVICECHECK_KEY_ID: ${APPLE_DEVICECHECK_KEY_ID:-} APPLE_DEVICECHECK_SIGNING_PEM: ${APPLE_DEVICECHECK_SIGNING_PEM:-} APPLE_DEVICECHECK_ENV: ${APPLE_DEVICECHECK_ENV:-production} # Runtime: opt-in world-loop perf diagnostics (PERF_TICK_LOG=1): the 20 Hz # heartbeat log line (tickHz, per-phase p95/max) plus over-budget stutter # traces. Off by default so production logs stay quiet. PERF_TICK_LOG: ${PERF_TICK_LOG:-} # Runtime: the published OTA bundle manifest POST /api/ota/updates reads # (server/ota_updates.ts). Empty (unset) keeps OTA dark: the endpoint # answers "no update" and the native apps keep their current bundle. Must # be https, and must share its origin with the bundle URLs inside it. OTA_MANIFEST_URL: ${OTA_MANIFEST_URL:-} OTA_DELTA_DISABLED: ${OTA_DELTA_DISABLED:-} # Runtime: bearer token that gates GET /metrics (server/http/health.ts). # Empty (unset) leaves the endpoint feature-off (404); a non-empty value # turns it on behind Authorization: Bearer . Set on the host .env by # the monitoring deploy so Prometheus can scrape the RED + process metrics. METRICS_TOKEN: ${METRICS_TOKEN:-} # Runtime: the ONLY gate on the deploy countdown endpoint (nothing # restricts /internal/* to loopback), so treat it as a real secret. RESTART_COUNTDOWN_SECRET: ${RESTART_COUNTDOWN_SECRET:-} # Runtime: Discord OAuth + the secret-gated game <-> Discord bot channel. DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:-} DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:-} DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-} DISCORD_GUILD_INVITE: ${DISCORD_GUILD_INVITE:-} DISCORD_BOT_SECRET: ${DISCORD_BOT_SECRET:-} # Runtime: the game server uses the bot token to auto-join a linking player to # the official guild (guilds.join). Empty disables auto-join (verify-only). DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN:-} # Runtime: the /api/discord status cache levers (server/discord_status_cache.ts; # operator rows in DEPLOY.md). Empty, non-numeric, or non-positive reads as the # in-code default (15000 ms TTL, 2000 entries), so an unset key is always safe. DISCORD_STATUS_CACHE_TTL_MS: ${DISCORD_STATUS_CACHE_TTL_MS:-} DISCORD_STATUS_CACHE_MAX_ENTRIES: ${DISCORD_STATUS_CACHE_MAX_ENTRIES:-} # Runtime: GitHub OAuth for the in-game developer badge. Both id+secret must be # set to enable "Link GitHub"; empty hides it exactly like Discord. GITHUB_REPO # selects which repo's contributor stats back the badge (code default: # levy-street/world-of-claudecraft); GITHUB_TOKEN lifts the GitHub API rate limit # for the contributor + News/Releases fetches. See .env.example. GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-} GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-} GITHUB_REPO: ${GITHUB_REPO:-} GITHUB_TOKEN: ${GITHUB_TOKEN:-} WOC_DAILY_REWARD_SERVICE_URL: ${WOC_DAILY_REWARD_SERVICE_URL:-} WOC_DAILY_REWARD_SERVICE_SECRET: ${WOC_DAILY_REWARD_SERVICE_SECRET:-} WOC_DAILY_REWARD_CONFIG_TTL_MS: ${WOC_DAILY_REWARD_CONFIG_TTL_MS:-} WOC_ECONOMY_SERVICE_URL: ${WOC_ECONOMY_SERVICE_URL:-} WOC_ECONOMY_INTERNAL_SECRET: ${WOC_ECONOMY_INTERNAL_SECRET:-} SOLANA_RPC_URL: ${SOLANA_RPC_URL:-} WOC_MINT: ${WOC_MINT:-} META_CAPI_PIXEL_ID: ${META_CAPI_PIXEL_ID:-} META_CAPI_ACCESS_TOKEN: ${META_CAPI_ACCESS_TOKEN:-} META_CAPI_TEST_EVENT_CODE: ${META_CAPI_TEST_EVENT_CODE:-} META_CAPI_API_VERSION: ${META_CAPI_API_VERSION:-} # Runtime: outbound email (server/email/sender.ts). All empty → console # transport (mail is logged, not sent). EMAIL_PROVIDER=ses selects AWS SES # (needs EMAIL_SES_REGION or AWS_REGION plus EMAIL_FROM; credentials via the # SDK default chain: IAM instance role, or the AWS_* keys below). Otherwise # EMAIL_API_URL+EMAIL_API_KEY+EMAIL_FROM selects the generic HTTP transport. EMAIL_PROVIDER: ${EMAIL_PROVIDER:-} EMAIL_FROM: ${EMAIL_FROM:-} EMAIL_BASE_URL: ${EMAIL_BASE_URL:-} EMAIL_SES_REGION: ${EMAIL_SES_REGION:-} AWS_REGION: ${AWS_REGION:-} AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} EMAIL_API_URL: ${EMAIL_API_URL:-} EMAIL_API_KEY: ${EMAIL_API_KEY:-} # Optional Steam account link and Book of Deeds achievement mirror. STEAM_ENABLED: ${STEAM_ENABLED:-} STEAM_APP_ID: ${STEAM_APP_ID:-} STEAM_WEB_API_KEY: ${STEAM_WEB_API_KEY:-} # Optional Epic account link and Book of Deeds achievement mirror. # Dark unless EPIC_ENABLED is exactly 1 (see DEPLOY.md). Secrets stay on # the host .env; never commit real values. EPIC_ENABLED: ${EPIC_ENABLED:-} EPIC_PRODUCT_ID: ${EPIC_PRODUCT_ID:-} EPIC_SANDBOX_ID: ${EPIC_SANDBOX_ID:-} EPIC_DEPLOYMENT_ID: ${EPIC_DEPLOYMENT_ID:-} EPIC_CLIENT_ID: ${EPIC_CLIENT_ID:-} EPIC_CLIENT_SECRET: ${EPIC_CLIENT_SECRET:-} # Optional deployable SFX packs live outside the image. Export from SFX # Studio, run its installer against the host overlay root, and this server # reads only the stable runtime manifest plus immutable audio blobs here. SFX_PACK_DIR: /app/sfx-runtime/audio/sfx # Combat parse capture (server/parse/): inert unless PARSE_CAPTURE=1 and # an ingest URL is set. The spool dir must live on the mounted volume # below so batches survive a container restart. PARSE_CAPTURE: ${PARSE_CAPTURE:-} PARSE_INGEST_URL: ${PARSE_INGEST_URL:-} PARSE_INGEST_TOKEN: ${PARSE_INGEST_TOKEN:-} PARSE_CAPTURE_SURFACES: ${PARSE_CAPTURE_SURFACES:-} PARSE_SPOOL_DIR: ${PARSE_SPOOL_DIR:-/app/parse-spool} PARSE_SPOOL_MAX_MB: ${PARSE_SPOOL_MAX_MB:-} PARSE_ENV_LABEL: ${PARSE_ENV_LABEL:-} PARSE_CENSUS: ${PARSE_CENSUS:-} PARSE_CENSUS_HOUR: ${PARSE_CENSUS_HOUR:-} WOC_BUILD_VERSION: ${WOC_BUILD_VERSION:-} # loopback-only: the TLS reverse proxy (Caddy) is the public entrance ports: - "127.0.0.1:8787:8787" volumes: - ${EASTBROOK_MEDIA_DIR:-./media-cache}:/app/dist/media - ${EASTBROOK_SFX_DIR:-./sfx-runtime}:/app/sfx-runtime:ro - ${PARSE_SPOOL_HOST_DIR:-./parse-spool}:/app/parse-spool extra_hosts: - "host.docker.internal:host-gateway" # Exec form with a node one-liner, deliberately unlike the CMD-SHELL healthcheck # the postgres service uses above: the runtime image is a Debian slim base # (node:26-slim) and has no curl or wget, but it does ship node. The probe hits # /livez and nothing else. /livez reports a WEDGED game loop, not merely a live # process, so it catches the failure # `restart: unless-stopped` cannot see (that policy only acts on process exit), and # it deliberately stays 200 while the server drains, so a graceful shutdown is never # misread as a wedge. /readyz flips to 503 while draining and would do exactly that. # The probe must exit nonzero on BOTH a non-200 status and a refused connection, # hence the explicit statusCode check and the 'error' handler: a bare http.get exits # 0 on a 503 and throws on a refusal. start_period covers a cold boot (the DB retry # loop, the schema sweep, the cache warms); failures inside it do not spend retries. healthcheck: test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:8787/livez',(r)=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"] interval: 15s timeout: 5s retries: 4 start_period: 60s # Time docker allows between SIGTERM and SIGKILL. Covers the realistic shutdown # path: the character/market/mail saves, ending play sessions, the bank-ledger and # deed-record drains, the Steam mirror's 5s deadline, releasing character leases and # closing the pool, with headroom over the runbook's `docker compose stop -t 60 # game` fallback. It is not a strict bound: an adversarial worst case (a character # save riding the 65s driver backstop, plus the deadline-free FIFO drains) can still # run past it and be killed. stop_grace_period: 75s # Container memory ceiling. The default suits a host with about 8 GiB of RAM: 5 GiB # here holds the recommended 4096 MiB V8 heap (NODE_OPTIONS above) plus native and # buffer overhead, and still leaves room for the database container and the OS. # Resize it to the host you are running on. Without a ceiling a runaway game process # can starve the database and the OS and take the whole host down rather than just # itself. mem_limit is the direct runtime Compose Spec key # and is honored unconditionally by `docker compose up`; prefer it over the # swarm-shaped deploy.resources.limits.memory block, which Compose v2 does honor for # `up` but which is the swarm surface (and was a silent no-op under compose v1). mem_limit: 5g # Pin swap to the memory limit so the container cannot lean on host swap: docker # defaults total memory+swap to twice mem_limit when this is unset, so on a host with # swap a leaking process would thrash up to 5g of swap before the OOM kill, which is # slower and messier than the clean in-container kill the limit exists to produce. memswap_limit: 5g discord-bot: image: eastbrook-game:${EASTBROOK_IMAGE_TAG:-local} container_name: eastbrook-discord-bot profiles: - discord restart: unless-stopped depends_on: game: condition: service_started environment: GAME_SERVER_URL: http://game:8787 PUBLIC_GAME_URL: ${PUBLIC_GAME_URL:-http://localhost:8787} DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN:-} DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:-} DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-} DISCORD_BOT_SECRET: ${DISCORD_BOT_SECRET:-} DISCORD_VOICE_CHANNEL_ID: ${DISCORD_VOICE_CHANNEL_ID:-} DISCORD_WELCOME_CHANNEL_ID: ${DISCORD_WELCOME_CHANNEL_ID:-} DISCORD_TEST_CHANNEL_ID: ${DISCORD_TEST_CHANNEL_ID:-} DISCORD_RELAY_CHANNEL_ID: ${DISCORD_RELAY_CHANNEL_ID:-} DISCORD_ACTIVITY_CHANNEL_ID: ${DISCORD_ACTIVITY_CHANNEL_ID:-} DISCORD_DAILY_REWARDS_CHANNEL_ID: ${DISCORD_DAILY_REWARDS_CHANNEL_ID:-} DISCORD_SYNC_NICKNAMES: ${DISCORD_SYNC_NICKNAMES:-1} # Runtime: every tuning knob the bot reads, so an operator can turn the # incident levers DEPLOY.md documents (the request ceiling, the ban pause, # the breaker limit, the forbidden-channel TTL, and the sweep and outbox # cadences) without editing and rebuilding the image. The bot code already # read all of these; compose forwarded none of them, so on the host every # documented lever was inert and the only way to change one was a rebuild. # Empty when unset is safe by design: the bot's positiveNumberFromEnv # rejects '' and falls back to its built-in default, exactly as if the key # were absent. Repeating the code defaults as compose defaults was rejected # for the opposite reason: it would put the same numbers in two places and # let them drift with nothing to notice. DISCORD_MAX_RPS: ${DISCORD_MAX_RPS:-} DISCORD_BAN_PAUSE_MS: ${DISCORD_BAN_PAUSE_MS:-} DISCORD_BREAKER_LIMIT: ${DISCORD_BREAKER_LIMIT:-} DISCORD_FORBIDDEN_TTL_MS: ${DISCORD_FORBIDDEN_TTL_MS:-} DISCORD_ROLE_SYNC_INTERVAL_MS: ${DISCORD_ROLE_SYNC_INTERVAL_MS:-} DISCORD_PRESENCE_DEBOUNCE_MS: ${DISCORD_PRESENCE_DEBOUNCE_MS:-} DISCORD_SWEEP_SLICE_MS: ${DISCORD_SWEEP_SLICE_MS:-} DISCORD_SWEEP_SLICE_SIZE: ${DISCORD_SWEEP_SLICE_SIZE:-} DISCORD_OUTBOX_POLL_MS: ${DISCORD_OUTBOX_POLL_MS:-} DISCORD_OUTBOX_IDLE_MS: ${DISCORD_OUTBOX_IDLE_MS:-} DISCORD_OUTBOX_TIMEOUT_MS: ${DISCORD_OUTBOX_TIMEOUT_MS:-} # Runtime: the liveness heartbeat. The first two are read by the bot (where # it stamps the file, and how often); the third is read ONLY by the # healthcheck one-liner below, which decides how old a stamp may be before # the container is called unhealthy. Keeping the staleness knob out of the # bot is deliberate: the writer must not be able to widen the window the # prober judges it by. DISCORD_HEARTBEAT_FILE: ${DISCORD_HEARTBEAT_FILE:-} DISCORD_HEARTBEAT_INTERVAL_MS: ${DISCORD_HEARTBEAT_INTERVAL_MS:-} DISCORD_HEARTBEAT_STALE_MS: ${DISCORD_HEARTBEAT_STALE_MS:-} command: ["node", "dist-bot/bot.cjs"] # Exec form with a node one-liner for the same reason the game service uses one # (node:26-slim ships neither curl nor wget), but it cannot reuse the game's # probe shape: the bot serves no HTTP port, so there is no /livez to hit. It # tests the FRESHNESS of the heartbeat file instead. Freshness, not existence: # a file that exists proves the process started once, while an mtime inside the # window proves the scheduler loop is still turning, which is the failure that # actually happens (a live process whose loops have stalled). Missing, # un-stat-able (a denied path), and stale all exit nonzero, hence the try/catch # around statSync. A chmod-000 heartbeat FILE still stats fine (stat needs no # read permission) and reads healthy while fresh, which is correct: mtime is the # only evidence the probe needs, and a file the bot cannot WRITE stops advancing # and goes stale within one window anyway. # The s>0?s:90000 guard is deliberate: compose passes '' for an unset host key # and Number('') is 0, a garbage value is NaN, and a negative would make the # comparison unsatisfiable (permanently unhealthy); none of the three is > 0, # so all fall back to the default, matching the bot's own positiveNumberFromEnv. # The path is trimmed because bot/config.ts trims it: without the matching trim # a padded override would send the write and the stat to two different names. # start_period covers the first jittered write, which can land up to about 33s # after boot. Note what this does NOT do: a red healthcheck never restarts the # container, because `restart: unless-stopped` acts on process exit only. It is # the operator signal DEPLOY.md documents, and the reason the probe is worth # having even so. healthcheck: test: ["CMD", "node", "-e", "const fs=require('fs');const p=(process.env.DISCORD_HEARTBEAT_FILE||'').trim()||'/tmp/discord-bot-heartbeat';const s=Number(process.env.DISCORD_HEARTBEAT_STALE_MS);const stale=s>0?s:90000;try{process.exit(Date.now()-fs.statSync(p).mtimeMs