CLAUDE.md #
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this repo is #
Docker Compose orchestration for deploying The Lacuna Expanse (TLE), a browser MMO. It contains
no application source — every service except logrotate runs a prebuilt image pulled by
:latest tag from the private registry at $CONTAINER_REGISTRY_URL (a Gitea instance). To change
server, frontend, or v2 API behaviour you work in the sibling repos (../server Perl backend, the
repos behind the app / alliance-starmap / super-ui frontend images, ../v2-api) and publish
new images; this repo only wires them together and supplies runtime config.
The initial commit used a per-service components/<name>/docker-compose.yml layout. That has been
collapsed into the single root docker-compose.yml. Some Dockerfile comments still reference
docker/docker-compose.homelab.yml — that path is from the ../server repo, not this one; the
canonical compose file here is ./docker-compose.yml.
Common commands #
docker compose pull # fetch prebuilt images from the registry
docker compose build # build the one local image (logrotate)
./refresh-static-files.sh # (re-)populate ./data/public-static from the pulled server image
docker compose up -d
docker compose logs -f server
docker compose run --rm server bash # shell inside the Perl server image
First-time bring-up (inside docker compose run --rm server bash):
mysql # client is preconfigured for the mysql container
mysql> source setup.sql # creates the `lacuna` db + user
mysql> exit
cd setup
perl init_lacuna.pl # game world initialisation
perl generate_captcha.pl
Architecture #
All services share the lacuna Docker network; the compose project name is also lacuna.
Request path. caddy (official caddy:2.8-alpine image, config caddy/Caddyfile bind-mounted
at /etc/caddy/Caddyfile) is the front proxy and the public internet-facing edge: exposed
directly on the open web (no Tailscale), publishing host ports 80, 443, and 443/udp (HTTP/3). It
terminates TLS itself via automatic HTTPS — Let's Encrypt certs for $CADDY_DOMAIN (set in .env;
both the compose file and the Caddyfile fall back to meanjin-one.tlecommunity.com), with an
automatic HTTP→HTTPS redirect. This needs the domain's DNS pointed at the deploy host and the
persistent ./data/caddy-data volume for the cert store. server_url in etc/lacuna.conf must
match $CADDY_DOMAIN. For a local run without DNS/TLS, set CADDY_DOMAIN=http://localhost so Caddy
just listens on :80.
Routing (caddy/Caddyfile): /v2/* → v2-api:5999; /app/*, /alliance-starmap/*, /super-ui/*
→ the matching SPA container on :3000; /captcha/*, /server/*, /network19/*,
/missioncommand/* are served as static files from the corresponding ./data/public-* host dirs;
everything else falls through to a handler rooted at ./data/public-static that returns a matching
static file if one exists and otherwise reverse-proxies to server:5000. ./data/public-static
holds changes.txt/resources.json/dummy-captcha.png/robots.txt extracted from the server
image's own public/ directory by refresh-static-files.sh (run by deploy.sh on every deploy,
between pull and up) — those files are edited as normal changes in the ../server repo, not
here; nothing in this repo should hand-edit them. A bare docker compose up -d (skipping
deploy.sh) leaves ./data/public-static empty/stale on a fresh or long-idle host — run
./refresh-static-files.sh by hand first, or after, to populate it.
Backends.
server— Perl/Plack v1 JSON-RPC backend, imagetlecommunity/server, listens on 5000 with no published host port (reached throughcaddy). Runs the image's defaultstart_lacuna.shCMD (the explicitcommand:is commented out).TLE_NO_MIDDLEWARE=1disables the prod size-limit/profiling middleware — this has been implicated in past crashes (it's what's now disabled) rather than being a deliberate safety net we rely on; worker recycling instead comes from Starman's own--max-requests(seestart_lacuna.shin the server repo) plus a fork-safe DB connection (AutoInactiveDestroyinLacuna.pm).LACUNA_LOG_DIRsets the log root. Compose healthcheck hits/starman_ping.- Diagnosing a worker crash / "everything went down" incident:
dmesg -T | grep -i 'killed process'orjournalctl -k | grep -i oomon the host for kernel OOM-killer events (nothing here captures these automatically yet);docker inspect server --format '{{json .State}}'after a restart — theOOMKilledfield distinguishes a container-level memory-limit kill from other causes, once/ifmem_limitis added to this service; the Beszel dashboard for host/container memory trend lines around the incident window.
- Diagnosing a worker crash / "everything went down" incident:
v2-api— Node v2 REST backend (node index.ts), imagetlecommunity/v2-api, listens on$PORT(5999); no published host port.app/alliance-starmap/super-ui— prebuilt frontend + admin SPA images, each serving on:3000internally and reached under the/app/,/alliance-starmap/,/super-ui/path prefixes; no published ports. Each keeps its own./data/<name>-caddy-{data,config}volumes.
Data + infra. None of these publish host ports (only caddy does). mysql 5.5 (root password
from LACUNA_MYSQL_ROOT_PASSWORD; compose healthcheck via mysqladmin ping), with mysql-dump.sh
bind-mounted in and nightly logical dumps written to ./data/backups (7-day retention, driven by
the Ofelia mysql-dump job). memcached for sessions/cache — currently ephemeral; the
file-persistence command/volumes in docker-compose.yml are commented out. beanstalk job
queue (schickling/beanstalkd) with beanstalk-console.
Schedulers — all reuse the tlecommunity/server image:
server-building-scheduler,server-ship-scheduler,server-captcha-schedulereach run one long-livedrun_scheduler.sh schedule_*.pldaemon.server-script-schedulerruns Ofelia (mcuadros/ofelia,schedule.inimounted at/etc/ofelia.conf), which via the mounted/var/run/docker.sockexecs therun_{hourly,two_hourly,four_hourly,daily,weekly}.shbatch scripts inside theservercontainer and the nightlymysql-dump.sh(04:30) inside themysqlcontainer.
logrotate — Alpine sidecar (Dockerfile.logrotate, entrypoint-logrotate.sh) that runs
logrotate once a day against the shared ./log tree using the policy in lacuna.logrotate
(copytruncate, since producers in other containers hold log handles open with no reopen signal).
Management. phpmyadmin, portainer-agent, and deploy-trigger are each reachable only
over Tailscale, never through caddy or the public internet (no Caddyfile route exists for any of
them). Each follows the same two-container pattern: a <name>-tailscale sidecar
(tailscale/tailscale:latest) owns the network namespace — its own TS_AUTHKEY, tun device,
net_admin/sys_module caps, tsnet state under ./data/tailscale-<name>, and a Tailscale Serve
config at ./tailscale/<name>/<name>.json (AllowFunnel: false — tailnet-only) — while the real
service container joins it via network_mode: service:<name>-tailscale with no ports/caps of its
own. phpmyadmin proxies to its own apache on :80; portainer-agent (EDGE-mode,
/var/run/docker.sock
/:/hostmounted) proxies to:9001.deploy-triggeris an HMAC-gated webhook (adnanh/webhook, built viaDockerfile.deploy-trigger, config in./tailscale/deploy-trigger/hooks.json) that CI POSTs to in order to trigger a deploy. It deliberately does not rundeploy.shor touch docker itself —deploy.shneeds root, and giving a network-reachable container that power (whether viasudoor a mounted docker socket) would just relocate the same risk. Insteadhooks.jsonruns./tailscale/deploy-trigger/receive-deploy.sh, which records the request (with CI-suppliedactor/ref/commit/ci_run_url) into./data/deploy-requests/and appends a line to./log/deploy/deploy-audit.log. A host-level systemd.path/.servicepair (./systemd/deploy-trigger.path,./systemd/deploy-trigger.service,./systemd/run-queued-deploy.sh— see./systemd/README.mdfor the one-time install) is the only thing outside Docker Compose in this repo: it watches that directory and, running as root, actually executesdeploy.sh, logging full output to./log/deploy/deploy-<timestamp>.logand appending the outcome (succeeded/failed, exit code, duration) to the audit log.
Configuration #
Gitignored, copy from the *.example sibling and fill in:
.env(from.env.example) —CONTAINER_REGISTRY_URL,LACUNA_MYSQL_ROOT_PASSWORD,CADDY_DOMAIN(front-proxy public hostname),TAILSCALE_AUTHKEY,PORTAINER_AGENT_EDGE_ID/PORTAINER_AGENT_EDGE_KEY,DEPLOY_WEBHOOK_SECRET(HMAC secret CI signs deploy-trigger requests with).etc/lacuna.conf(frometc/lacuna.example.conf) — the main game config: JSON-with-#-comments. Covers db DSN, beanstalk/memcached endpoints, captcha fonts, map size and neutral/starter zones, payment gateways,server_url/assets_url.
Tracked config and scripts: etc/log4perl.conf (Perl logging — INFO to stderr +
log/server/lacuna.log), caddy/Caddyfile, schedule.ini (Ofelia cron), setup.sql (db + user
bootstrap), mysql-dump.sh (nightly db backup, bind-mounted into mysql), Dockerfile.logrotate +
entrypoint-logrotate.sh + lacuna.logrotate (the logrotate sidecar), deploy.sh (pull →
refresh-static-files.sh → up -d --remove-orphans → image prune), refresh-static-files.sh
(extracts changes.txt/resources.json/dummy-captcha.png/robots.txt from the pulled server
image into ./data/public-static, see Architecture → Request path), ./tailscale/<name>/ (Serve configs for the Management
services, see Architecture), Dockerfile.deploy-trigger +
./tailscale/deploy-trigger/{hooks.json, receive-deploy.sh}, and ./systemd/ (the host-installed
deploy-trigger units — the one thing here Compose doesn't manage). Repo files are
Prettier-formatted: npm run format:check / npm run format:fix (config in .prettierrc).
Host-mounted trees #
Created on the deploy host, mostly not in git:
./var— shared runtime tree betweenserverandcaddy(generated static assets, captcha fonts undervar/fonts)../log(gitignored) — unified log tree (server/,scheduler/,cron/,deploy/,caddy/). Thelogrotatesidecar rotates all of it exceptcaddy/, whose access log Caddy rolls itself../tmp— server scratch, mounted at/tmpinserverand the schedulers. (./captchais a leftover empty dir; captcha images now land in./data/public-captcha.)./data/*(gitignored) — persistent state:mysql(+data/backupsfor dumps);caddy-data/caddy-configfor the front proxy (the former holds the ACME account and issued TLS certs); per-SPAapp-/alliance-starmap-/super-ui-caddy-data+caddy-configdirs;public-captcha/public-network19/public-missioncommand/public-server— written byserver, read bycaddyfor the static routes; andpublic-static— written byrefresh-static-files.sh(extracted from theserverimage, not by the running container), read bycaddyas its fallback root (see Architecture → Request path). (data/memcachedis used only if memcached persistence is re-enabled.)tailscale-phpmyadmin/tailscale-portainer-agent/tailscale-deploy-triggerhold each Management sidecar's tsnet state.deploy-requestsis not persistent state but a transient work queue:deploy-triggerwrites one JSON file per triggered deploy there, and the hostdeploy-trigger.service(see Architecture → Management) deletes it oncedeploy.shhas run.