Docker files for the TLE Community deployment
deployment CLAUDE.md
12 kB
Markdown
at main

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, 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/<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-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 <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

  • /:/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-<timestamp>.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/<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 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.