Skip to content

Running MAID Engine in Docker

The MAID Engine ships with a hardened, multi-stage Dockerfile at the repo root. The image is designed for production deployment alongside the bare-metal readiness work in docs/impl/bare-metal-readiness/plan.md (milestone M7): it runs as a non-root user, uses tini as PID 1, mounts state and config from external volumes, and exposes a built-in HEALTHCHECK that calls maid status --json.

Image layout

Layer Base Purpose
frontend-builder node:20-bookworm-slim Installs frontend dev dependencies and builds admin/player frontend bundles.
builder python:3.12-slim Installs uv, copies the built frontend bundles, resolves uv.lock, builds /opt/maid/.venv from --no-dev deps.
runtime python:3.12-slim Copies in the venv + source, installs tini, creates non-root users, sets healthcheck and entrypoint.

The runtime stage creates four service accounts so that bind-mounted host directories from a matching bare-metal install do not suffer ownership drift:

User UID/GID Role
maid-engine 1000 The engine process (default USER).
maid-ops 1001 Operator tooling / backups.
maid-admin 1002 Admin/API endpoints.
maid-readonly 1003 Read-only consumers (metrics, dashboards).

Volumes

The container declares four volumes that line up with the bare-metal filesystem layout:

Mountpoint Mode Owner Purpose
/etc/maid ro root:maid-engine (0750) Engine configuration (.env, settings.toml, secrets).
/var/lib/maid rw maid-engine Persistent state (databases, save files, plugin state).
/var/log/maid rw maid-engine Structured logs, audit trails.
/run/maid-engine rw maid-engine Runtime artefacts (sockets, pidfiles, ready sentinels). Matches DeploySettings.runtime_dir default in maid_engine.config.settings.

Build

The Dockerfile accepts three build args so that image metadata reflects the underlying release:

docker build \
    --build-arg MAID_VERSION="$(git describe --tags --always)" \
    --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
    --build-arg GIT_SHA="$(git rev-parse HEAD)" \
    -t ghcr.io/qworg/maid:latest \
    .

These values are surfaced as OCI image labels (org.opencontainers.image.{title,version,revision,created,source,licenses}) and as the MAID_VERSION, MAID_GIT_SHA, and MAID_BUILD_DATE environment variables inside the container.

Run

A minimal invocation that wires the standard volumes and exposes the default telnet + web ports:

docker run --rm -it \
    --name maid \
    -v /etc/maid:/etc/maid:ro \
    -v maid-state:/var/lib/maid \
    -v maid-logs:/var/log/maid \
    --tmpfs /run/maid-engine:uid=1000,gid=1000,mode=0750 \
    -p 4000:4000 \
    -p 8080:8080 \
    ghcr.io/qworg/maid:latest

The default CMD is ["server", "start"]. Any other CLI command (for example db migrate or status --json) can be passed as additional arguments and is forwarded to the maid entrypoint:

docker run --rm \
    -v /etc/maid:/etc/maid:ro \
    -v maid-state:/var/lib/maid \
    ghcr.io/qworg/maid:latest db migrate

Healthcheck

The image declares:

HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
    CMD maid status --json || exit 1

maid status --json prints the deploy-contract identity (instance name, runtime/log dirs, tier, socket paths) and exits non-zero when the engine cannot determine its identity. The 60-second start period gives the tick loop, networking, and the ops UDS time to come up before the first probe is scored. Inspect the most recent probe with:

docker inspect --format '{{json .State.Health}}' maid | jq

A failing healthcheck is the supported signal for orchestration layers (systemd, Nomad, Kubernetes) to recycle the container.

Entrypoint flow

The image's ENTRYPOINT chains tini/usr/local/bin/docker-entrypoint.sh/opt/maid/.venv/bin/maid. The shim (scripts/docker-entrypoint.sh) runs as root and:

  1. Iterates the writable volumes (/var/lib/maid, /var/log/maid, /run/maid-engine) and chown -Rs any directory that is not already owned by uid 1000 (maid-engine). This repairs the ownership drift that bind-mounts introduce on a fresh host.
  2. Drops privileges to maid-engine via gosu and execs the command (default: server start). gosu is preferred over su or sudo because it never forks and does not allocate a tty.
  3. If the container is already started with --user (e.g. docker run --user 1000:1000 ...), the chown loop becomes a no-op and the script execs the command directly.

/etc/maid is intentionally never chowned: it is expected to be mounted read-only and is shipped as root:maid-engine 0750 so the engine can still read settings files.

Composing with the systemd reference deployment

The systemd unit shipped under packaging/systemd mirrors the container's filesystem layout. If you run both side by side on a single host, point the container's bind mounts at the systemd directories to share state cleanly:

docker run --rm \
    -v /etc/maid:/etc/maid:ro \
    -v /var/lib/maid:/var/lib/maid \
    -v /var/log/maid:/var/log/maid \
    -v /run/maid-engine:/run/maid-engine \
    --user 1000:1000 \
    ghcr.io/qworg/maid:latest status --json

The matching UIDs ensure that files written by either runtime remain readable by the other.

One-command remote install

For a single-host Docker deployment, scripts/install-docker.sh builds the image from the current checkout, creates /etc/maid/<instance>.env with generated admin/database secrets when needed, starts a dedicated PostgreSQL container, bootstraps the MAID durability/document schema, replaces the running MAID container, and waits for Docker healthchecks:

sudo scripts/install-docker.sh --internet-facing --domain mud.example.com

By default the script binds the web UI to 127.0.0.1:8080 and telnet TLS to 0.0.0.0:4004. Plain telnet is disabled. For internet-facing deployments, put Caddy/nginx in front for TLS and restrict/admin-protect the browser UI shell at /admin-ui*. Leave /admin/* reachable by the same origin so the SPA can call /admin/auth/login; MAID protects the admin API with JWT cookies, CSRF, and role checks. When --domain is provided, the script writes /etc/maid/Caddyfile.maid.example with a reverse-proxy example and a placeholder basic_auth block for the admin surfaces.

The helper creates a private Docker network plus a PostgreSQL data volume (default: maid-postgres-data). It writes production settings with MAID_PERSISTENCE_ENABLED=true, configures the app role, applies the SQL bootstrap under packaging/postgres/, and stores world state in PostgreSQL. Re-running the script is idempotent: it keeps the existing database volume and reuses secrets already present in the env file.

It also bootstraps an initial panel super-administrator for /admin-ui/. This does not grant the account elevated in-game commands. The default username is admin; the generated password is stored in the env file as MAID_BOOTSTRAP_ADMIN_PASSWORD:

sudo grep '^MAID_BOOTSTRAP_ADMIN_PASSWORD=' /etc/maid/default.env

TLS telnet for Mudlet

The Docker installer exposes encrypted telnet/MUD client access on port 4004 and sets MAID_TELNET__SSL_ONLY=true, so plain telnet is not started. Players using Mudlet should connect to:

Host: a.dventuring.com
Port: 4004
SSL/TLS: enabled

Populate /etc/maid/certs from Certbot before running the installer:

sudo certbot certonly --standalone -d a.dventuring.com
sudo scripts/renew-telnet-certs.sh --domain a.dventuring.com --no-restart
sudo scripts/install-docker.sh --internet-facing --domain a.dventuring.com

Install the renewal helper as a Certbot deploy hook so MAID restarts after renewed certs are copied:

sudo cp scripts/renew-telnet-certs.sh \
  /etc/letsencrypt/renewal-hooks/deploy/maid-telnet-certs.sh
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/maid-telnet-certs.sh

Use --dry-run to preview the commands without requiring Docker:

scripts/install-docker.sh --dry-run --internet-facing --domain mud.example.com

Testing

Container smoke tests live in tests/docker/test_dockerfile.py. They build the image, run maid --version, and assert that the default process executes as uid 1000. The tests automatically skip when docker is not on PATH or the daemon is unreachable, so they remain safe to run in sandboxed CI environments:

uv run pytest tests/docker/ -q

Convenience build script

For local development the repository ships scripts/docker-build.sh, which derives MAID_VERSION, BUILD_DATE, and GIT_SHA from the working git checkout and forwards extra flags to docker build:

# Default tag is maid:latest.
scripts/docker-build.sh

# Override the image tag or pass docker build flags as you would normally.
IMAGE_TAG=ghcr.io/qworg/maid:dev scripts/docker-build.sh --no-cache