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:
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:
- Iterates the writable volumes (
/var/lib/maid,/var/log/maid,/run/maid-engine) andchown -Rs any directory that is not already owned by uid1000(maid-engine). This repairs the ownership drift that bind-mounts introduce on a fresh host. - Drops privileges to
maid-engineviagosuandexecs the command (default:server start).gosuis preferred oversuorsudobecause it never forks and does not allocate a tty. - If the container is already started with
--user(e.g.docker run --user 1000:1000 ...), the chown loop becomes a no-op and the scriptexecs 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:
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:
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:
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:
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:
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: