# 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//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 ```bash 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`): ```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, image `tlecommunity/server`, listens on 5000 with no published host port (reached through `caddy`). Runs the image's default `start_lacuna.sh` CMD (the explicit `command:` is commented out). `TLE_NO_MIDDLEWARE=1` disables 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` (see `start_lacuna.sh` in the server repo) plus a fork-safe DB connection (`AutoInactiveDestroy` in `Lacuna.pm`). `LACUNA_LOG_DIR` sets the log root. Compose healthcheck hits `/starman_ping`. - **Diagnosing a worker crash / "everything went down" incident:** `dmesg -T | grep -i 'killed process'` or `journalctl -k | grep -i oom` on the host for kernel OOM-killer events (nothing here captures these automatically yet); `docker inspect server --format '{{json .State}}'` after a restart — the `OOMKilled` field distinguishes a container-level memory-limit kill from other causes, once/if `mem_limit` is added to this service; the Beszel dashboard for host/container memory trend lines around the incident window. - `v2-api` — Node v2 REST backend (`node index.ts`), image `tlecommunity/v2-api`, listens on `$PORT` (5999); no published host port. - `app` / `alliance-starmap` / `super-ui` — prebuilt frontend + admin SPA images, each serving on `:3000` internally and reached under the `/app/`, `/alliance-starmap/`, `/super-ui/` path prefixes; no published ports. Each keeps its own `./data/-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-scheduler` each run one long-lived `run_scheduler.sh schedule_*.pl` daemon. - `server-script-scheduler` runs Ofelia (`mcuadros/ofelia`, `schedule.ini` mounted at `/etc/ofelia.conf`), which via the mounted `/var/run/docker.sock` execs the `run_{hourly,two_hourly,four_hourly,daily,weekly}.sh` batch scripts inside the `server` container and the nightly `mysql-dump.sh` (04:30) inside the `mysql` container. **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 `-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-`, and a Tailscale Serve config at `./tailscale//.json` (`AllowFunnel: false` — tailnet-only) — while the real service container joins it via `network_mode: service:-tailscale` with no ports/caps of its own. `phpmyadmin` proxies to its own apache on `:80`; `portainer-agent` (`EDGE`-mode, `/var/run/docker.sock` - `/:/host` mounted) proxies to `:9001`. `deploy-trigger` is an HMAC-gated webhook (`adnanh/webhook`, built via `Dockerfile.deploy-trigger`, config in `./tailscale/deploy-trigger/hooks.json`) that CI POSTs to in order to trigger a deploy. It deliberately does **not** run `deploy.sh` or touch docker itself — `deploy.sh` needs root, and giving a network-reachable container that power (whether via `sudo` or a mounted docker socket) would just relocate the same risk. Instead `hooks.json` runs `./tailscale/deploy-trigger/receive-deploy.sh`, which records the request (with CI-supplied `actor`/`ref`/`commit`/`ci_run_url`) into `./data/deploy-requests/` and appends a line to `./log/deploy/deploy-audit.log`. A host-level systemd `.path`/`.service` pair (`./systemd/deploy-trigger.path`, `./systemd/deploy-trigger.service`, `./systemd/run-queued-deploy.sh` — see `./systemd/README.md` for the one-time install) is the only thing outside Docker Compose in this repo: it watches that directory and, running as root, actually executes `deploy.sh`, logging full output to `./log/deploy/deploy-.log` and 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` (from `etc/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//` (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 between `server` and `caddy` (generated static assets, captcha fonts under `var/fonts`). - `./log` (gitignored) — unified log tree (`server/`, `scheduler/`, `cron/`, `deploy/`, `caddy/`). The `logrotate` sidecar rotates all of it except `caddy/`, whose access log Caddy rolls itself. - `./tmp` — server scratch, mounted at `/tmp` in `server` and the schedulers. (`./captcha` is a leftover empty dir; captcha images now land in `./data/public-captcha`.) - `./data/*` (gitignored) — persistent state: `mysql` (+ `data/backups` for dumps); `caddy-data` / `caddy-config` for the front proxy (the former holds the ACME account and issued TLS certs); per-SPA `app-` / `alliance-starmap-` / `super-ui-` `caddy-data` + `caddy-config` dirs; `public-captcha` / `public-network19` / `public-missioncommand` / `public-server` — written by `server`, read by `caddy` for the static routes; and `public-static` — written by `refresh-static-files.sh` (extracted from the `server` image, not by the running container), read by `caddy` as its fallback root (see Architecture → Request path). (`data/memcached` is used only if memcached persistence is re-enabled.) `tailscale-phpmyadmin` / `tailscale-portainer-agent` / `tailscale-deploy-trigger` hold each Management sidecar's tsnet state. `deploy-requests` is not persistent state but a transient work queue: `deploy-trigger` writes one JSON file per triggered deploy there, and the host `deploy-trigger.service` (see Architecture → Management) deletes it once `deploy.sh` has run.