mx App Playbook: Building, Migrating, and Routing Apps the MechCrate Way
The operating manual for building apps with the mx (MechCrate) framework. Written for agents: follow this and every app gets the same anatomy, the same tooling, and — non-negotiably — URLs through the mx router, never bare localhost ports. All bracketed citations are repo-relative paths in the mech-crate repo; this doc was researched directly from source.
1. The Why
MechCrate is “a reusable set of Docker and Docker Compose conventions plus Makefile modules plus scripts that let you drop an app into a known structure and start building immediately” [README.md:61-70]. It standardizes four things: local development (compose with dev overrides), environment config (.env files split per service), filesystem layout (1:1 host-to-container mapping), and a Make-based CLI that is identical across every project.
The payoff is compounding: every project answers “how do I run this / see logs / shell in / build it” identically, agents never rediscover project conventions, and the global Traefik router eliminates port allocation entirely — every app is http://<name>.localhost, no conflicts, no winging it.
The folder contract is mandatory: “If a repo uses MechCrate, the structure must exist exactly like this” [README.md:136-163]. Partial adoption breaks tooling and agent detection (§3).
Three core conventions [README.md:165-186, docs/product-structure.md:39-44]:
- Centralized env layering, loaded in order:
.env.shared→.env.secrets→.env.<service>. - Atomic service files — each service is its own compose file in
docker/compose/; stacks are composed by passing multiple-ffiles. - Baseline + dev override — production runs
service.ymlonly; development runsservice.yml+service.dev.yml. That one difference is the entire dev/prod split.
2. Project anatomy (what every mx app looks like)
mx new <name> emits exactly this [crates/mx-cli/src/commands/new.rs:56-163]:
my-app/├── Makefile # thin dispatcher; includes make/*.mk [templates/Makefile.template]├── README.md .gitignore # generated├── apps/ # service source code lands here (one dir per service)├── make/ # one .mk per command: dev, up, down, logs, sh, build, restart, ...├── scripts/ # the real logic; make targets delegate here (.bashrc = compose-layering lib)├── docker/│ ├── .config/ # .env.shared, .env.secrets(.template), .env.<service>│ ├── compose/ # ONE yml (+ one .dev.yml) PER SERVICE — starts EMPTY│ ├── system/ # 1:1 container filesystem mounts│ └── dockerfiles/ # <service>/app + app.prod└── tmp/up/ # snapshot of the exact compose -f set last startedLoad-bearing facts:
- A fresh
mx newproject has no services —docker/compose/is empty andmake devtells you tomx addfirst [templates/scripts/dev.sh]. Services only enter via recipes. - Detection markers: a directory is an mx project when
Makefile+docker/exist (lenient CLI check) — but agent-facing MCP tools require all four ofMakefile,docker/,make/,scripts/[crates/mx-lib/src/project.rs:45-66; crates/mx-mcp-server/src/project/mod.rs:42-47]. Migrations must create all four. - Compose layering mechanics:
scripts/.bashrcassembles the-ffile list — baselinedocker/compose/<svc>.yml, plus<svc>.dev.ymlonly in dev mode, plus an optional arch-specific$(uname -m).yml— and snapshots the resolved set intotmp/up/sodown/logs/psoperate on exactly what was started [templates/scripts/.bashrc:36-180]. - Daily loop:
make dev [s=svc](downs first, then up WITH dev overlays) ·make up(prod mode, no overlays) ·make down·make logs s=svc·make sh s=svc·make ps·make build s=svc [prod=1][templates/make/*.mk]. Agents have 1:1 MCP wrappers (make_dev,make_up,make_logs, …).
3. Starting a new app, front to back
mx init # once per machine: installs ~/.mech-crate (templates, recipes, router)mx router install && mx router up # once per machine: global Traefik (§5)mx new my-app [--infra cloudflare] # scaffold the project shellcd my-appmx add web --recipe=nuxt # or: astro | laravel | rust-api | rust-leptos | rust-worker | zolamx add api --recipe=rust-api --domain api.my-app.localhostmake dev # up with hot reload# → http://web.localhost, http://api.my-app.localhost — NO portsWhat mx add <name> --recipe=<r> does [crates/mx-cli/src/commands/add.rs; crates/mx-lib/src/recipe/installer.rs:71-123]: resolves the recipe’s recipe.json (services, options, placeholders, templates, post_install), optionally runs the framework’s own scaffolder (init_app, e.g. npx nuxi init), then Tera-interpolates templates into place: app source → apps/<name>/, compose baseline → docker/compose/<name>.yml, dev override → <name>.dev.yml, dockerfiles → docker/dockerfiles/<name>/, env → docker/.config/.env.<name>. The --domain option defaults to <name>.localhost and feeds the Traefik Host rule [templates/recipes/rust-api/recipe.json:23-26].
Recipe fragments come router-wired out of the box — the five Traefik labels, the devmesh-traefik network, and zero published app ports (§5). Databases/redis ride along via common:// fragments and stay off the router [templates/recipes/common/docker/compose/db.yml].
Gotchas (verified in source):
mx newrefuses to run in an existing directory [new.rs:37-39] — for existing apps use §4, notmx new.- The top-level
templates/docker/compose/*.yml(app.yml, nginx.yml, traefik.yml, worker.yml) are legacy reference samples: they publish host ports, joinmech-network, and carry no router labels. They are the anti-pattern this playbook exists to prevent — never copy them into a project. Canonical patterns live intemplates/recipes/*/docker/compose/service.yml. - Re-running
mx addwith an existing service name overwrites template-managed files unconditionally [installer.rs:252-299] — safe for regeneration, destructive for hand-edited compose files.
4. Migrating an existing app onto mx
There is no mx migrate; mx new won’t wrap existing code, and mx upgrade is currently broken against the template layout (it expects a templates/project/ dir that does not exist — [crates/mx-lib/src/upgrade/mod.rs:141-148]; fix tracked in the mx repo). Migration is a deliberate, mostly mechanical procedure:
Synthesis (inferred)
The following migration strategy is inferred from the folder contract, the recipe installer mechanics, and the detection rules above — it is the procedure an agent should follow, not a documented mx feature:
- Scaffold a sibling, steal the shell. Run
mx new <app>-mxnext to the existing repo, then copyMakefile,make/,scripts/,docker/(with its.config/compose/system/dockerfilesskeleton), andtmp/into the real repo. This satisfies the strict 4-marker detection contract in one move and guarantees current-template tooling. - Move source under
apps/<service>/. One directory per deployable unit. Keep the app’s own package management untouched — mx wraps, it does not replace. - Write the service compose pair by cloning the closest recipe, not the legacy samples: copy
templates/recipes/<closest>/docker/compose/service.yml+service.dev.yml, substitute the{{SERVICE_NAME}}/{{DOMAIN}}/port placeholders by hand (or runmx add <name> --recipe=<closest>in a scratch project and copy the rendered output). Adjustloadbalancer.server.portto the app’s internal port. Delete everyports:mapping for the app itself. - Split env into
docker/.config/.env.shared(project-wide),.env.secrets(gitignored; template checked in),.env.<service>— matching the documented load order. - Dockerfiles go to
docker/dockerfiles/<service>/app(dev) andapp.prod, built from repo root [docs/product-structure.md:39-41]; dev override setsbuild.target: development+ source bind-mount../../apps/<service>:/app:cachedfor hot reload. - Verify the contract:
mx doctor(checks structure, not router), thenmake devand confirm the URL resolves through the router (§5). An agent should treatproject_detectvia MCP returning true as the done-signal for the structural half. - Iterate one service at a time. A half-migrated repo where the old
docker-compose.ymlstill binds ports 80/443 will fight the router (§6, port conflicts) — retire legacy compose files as each service moves.
5. The Router Rule: every app URL goes through mx router
The rule (non-negotiable): apps are reached at http://<service>.localhost via the global mx router. Never publish an app port, never hand out http://localhost:<port>. A dev-only publish of a backing service (Postgres, Redis) or a dev tool is the only sanctioned ports: entry, and it must be ${VAR:-<default>} rather than a literal number [templates/recipes/*/docker/compose/service.dev.yml; enforced by crates/mx-lib/tests/templates_compose_ports.rs].
Architecture [templates/router/docker-compose.yml; crates/mx-lib/src/router/mod.rs; docs/router.md]: one global Traefik v3 container (mx-router) owning host ports 80/443 plus a dashboard on an auto-allocated port (7680–7799, cached in ~/.mech-crate/router/.dashboard-port). It watches the Docker socket (exposedByDefault: false) and routes to any container that opts in via labels on the external devmesh-traefik network. State lives in ~/.mech-crate/router/ (static config, hot-reloaded config/dynamic/, letsencrypt/). Routing updates are live — starting/stopping labeled containers re-routes with no router restart.
The five labels that give a service its URL (canonical block, identical across recipes) [templates/recipes/rust-api/docker/compose/service.yml:22-44]:
networks: - default # talk to db/redis - devmesh-traefik # be reachable by the routerlabels: - traefik.enable=true - traefik.http.routers.${COMPOSE_PROJECT_NAME}-<name>.rule=Host(`${<NAME>_ROUTER_HOST:-<name>.localhost}`) - traefik.http.routers.${COMPOSE_PROJECT_NAME}-<name>.entrypoints=web - traefik.http.services.${COMPOSE_PROJECT_NAME}-<name>.loadbalancer.server.port=<internal-port> - traefik.docker.network=devmesh-traefik# ...and at file bottom:networks: devmesh-traefik: external: trueloadbalancer.server.port is the app’s internal port (3000 for nuxt/rust-api, 80 for laravel, 4321 for astro) — it is never published to the host. Dev and prod carry identical routing labels; the URL does not change between make dev and make up.
The router, service and middleware names carry ${COMPOSE_PROJECT_NAME} (bd:mech-crate-298). Traefik keeps one table of each per provider for the whole machine, so a bare routers.api is a key every mx project on the workstation shares: mismatched labels make Traefik drop the router (Router defined multiple times with different configurations) and 404 both hostnames, matching labels merge both containers into one load-balancer pool that silently serves each other’s traffic. Compose resolves the variable from the project name it already resolved, so it holds even under a hand-rolled docker compose -f …. templates/router/ itself is the exception: it configures Traefik by file, and one machine has one router.
The Host rule’s value is ${<SERVICE_UPPER>_ROUTER_HOST:-<domain>} in shipped compose files, with the scaffolded domain in the default position. Hostnames did not change; the knob exists because two uniquely-named routers with identical rules are both accepted and Traefik serves exactly one, so a second same-shape stack claims its own hostname with API_ROUTER_HOST=api-two.localhost make dev instead of editing a shipped file.
URL discovery (how an agent finds the URL — instead of guessing localhost):
- Parse the service’s compose file with a YAML parser (never regex) for
traefik.http.routers.<n>.rule=Host(\h`), whereis now${COMPOSE_PROJECT_NAME}-. **hmay be${<SERVICE_UPPER>_ROUTER_HOST:-} , so resolve the interpolation: take the exported variable if set, otherwise the text after:-.** Printing the raw${…}as a hostname is a bug. Scheme ishttpsiff a siblingtls=truelabel exists; result is:// ` with no port [~/.claude/skills/devloop/references/url-discovery.md]. - Or read a running container’s labels (
docker inspect, the Traefik dashboard,docker compose -p <proj> config): those are already resolved, so no${…}survives and no defaulting logic is needed. Prefer this whenever the stack is up. - Or
mx router inspect— lists dashboard URL and connected services (the shell implementation enumeratesdocker network inspect devmesh-traefik) [bin/lib/router.sh:184-204]. - Reachability gate:
curl -k -I -m 5 <url>retried up to 30s; 2xx/3xx/401/403 all count as up. - Zero or multiple Host matches → stop and disambiguate with
mx router inspect+mx ps; never fall back to a localhost port.
Host ports are not pinned (bd:mech-crate-1a0), so guessing localhost:5432 was already wrong and now resolves to nothing. Every host-side publish in a shipped compose file is ${VAR:-<default>}: infra ports tooling dials on demand default to 0 and Docker allocates them (DB_HOST_PORT, REDIS_HOST_PORT, NODE_DEBUG_HOST_PORT, METRICS_HOST_PORT, VITE_HOST_PORT, INERTIA_SSR_HOST_PORT), browser-facing dev ports keep their number as the default and take an override (LEPTOS_RELOAD_HOST_PORT, ZOLA_LIVERELOAD_PORT, NGINX_HTTP_PORT/NGINX_HTTPS_PORT, TRAEFIK_HTTP_PORT/TRAEFIK_HTTPS_PORT/TRAEFIK_DASHBOARD_PORT, MX_ROUTER_HTTP_PORT/MX_ROUTER_HTTPS_PORT). Discover one with docker compose -p <proj> port <svc> <container-port>; pin one for a session by exporting the variable. Port 24678 no longer exists anywhere: astro and nuxt carry HMR over the app’s own port and the router upgrades it same-origin.
Lifecycle: mx router install (once; copies templates/router/ → ~/.mech-crate/router/, creates the network) · up / down · status · logs · inspect · network [crates/mx-cli/src/commands/router.rs]. Known implementation gaps: the Rust CLI’s inspect is an alias of status and reload (SIGHUP hot-reload) exists only in the shell lib [router.rs:175-177; bin/lib/router.sh:178-182]. HTTPS locally = mkcert cert + config/dynamic/certs.yml + switch labels to entrypoints=websecure/tls=true; production = ACME resolver + tls.certresolver=letsencrypt label [docs/router.md:364-421].
6. Router troubleshooting (when the URL doesn’t work)
Work this ladder top-down; each symptom isolates a different layer [docs/router.md:488-553 unless cited otherwise]:
| Symptom | Meaning | Fix |
|---|---|---|
curl http://x.localhost → connection refused | Router itself is down (nothing owns :80) | mx router status; mx router up; if “not installed” → mx router install |
Router won’t start: bind: address already in use | Something else owns 80/443 | lsof -i :80 / lsof -i :443; usual suspects: Apache, nginx, a legacy per-project traefik/nginx compose file — retire it |
| 404 from Traefik | Router is up but no route matches | Missing traefik.enable=true or traefik.docker.network=devmesh-traefik label, container not on the devmesh-traefik network, or Host rule typo. Verify: docker inspect <c> --format '{{json .Config.Labels}}' | jq and docker network inspect devmesh-traefik |
| 502 from Traefik | Route matches, backend unreachable | Container crashed/unhealthy, wrong loadbalancer.server.port, or app still booting — make logs s=<svc> |
Compose up fails: network devmesh-traefik not found | Router never installed/started on this machine | mx router install (creates the network) or mx router network |
| Dashboard URL unknown | Port is auto-allocated | mx router inspect or cat ~/.mech-crate/router/.dashboard-port (range 7680–7799) |
| Labels changed but routing didn’t | Stale container config | Recreate the service (make restart s=<svc>) — labels are read at container start; dynamic-file changes hot-reload, label changes need a container recreate |
Diagnostic bundle to gather when stuck: mx router status, mx router logs, docker network inspect devmesh-traefik, the service’s rendered labels, and make ps. Note: mx doctor performs zero router checks [crates/mx-cli/src/commands/doctor.rs] — passing doctor says nothing about routing.
Synthesis (inferred)
- Escalation rule for agents: if the ladder above doesn’t produce a working URL in two passes, the correct behavior is to surface the diagnostic bundle and ask — not to silently fall back to publishing a port. A temporary
ports:mapping “just to unblock” is how projects rot off the router; it defeats the entire convention and should be treated as a migration regression. .localhostresolution: the scheme relies on.localhostresolving to 127.0.0.1 (RFC 6761 behavior; modern browsers and macOS do this natively). curl on some setups may differ from the browser — if curl fails but the browser works, test withcurl --resolve <host>:80:127.0.0.1. The mx docs carry no OS-level resolver caveats today; if a machine genuinely doesn’t resolve.localhost, an/etc/hostsentry per hostname is the workaround.- Host-run (non-Docker) services can still get router URLs via a dynamic-file service pointing at
host.docker.internal[docs/router.md:481] — useful mid-migration when a service hasn’t been containerized yet.
7. Agent quick-reference
- New app:
mx new→mx add --recipe→make dev→ URL from compose Host labels. Never invent ports. - Existing app: §4 procedure — shell first, services one at a time, router from day one.
- Before touching a project:
project_detect(MCP) or check the 4 markers; before running anything:mx router status. - The URL is in the compose file. Read it; don’t guess it.