Recipe Authoring Guide
A comprehensive guide for creating MechCrate recipes—reusable application templates with Docker orchestration, process management, and reverse proxying.
Table of Contents
- Recipe Structure Overview
- The recipe.json Specification
- Docker Structure and Templating
- System Files and Filesystem Mirroring
- Supervisor Configuration
- Entrypoint Scripts
- Internal Reverse Proxy
- Environment Variables
- Compose Files
- Devmesh-Traefik Integration
- Best Practices
Recipe Structure Overview
A recipe is a self-contained template that generates a complete service with application code, Docker infrastructure, and orchestration. Each recipe follows this structure:
templates/recipes/[recipe-name]/├── app/ # Application source code│ ├── src/│ ├── config/│ └── ...├── config/│ └── env.service # Environment template for Docker├── docker/│ ├── compose/│ │ ├── service.yml # Production compose│ │ └── service.dev.yml # Development overrides│ ├── dockerfiles/│ │ ├── app.Dockerfile # Multi-stage Dockerfile│ │ └── app.prod.Dockerfile # Production-only build (optional)│ └── system/│ └── app/ # Filesystem mirror for container│ ├── etc/│ │ ├── supervisor/│ │ └── nginx/ (or haproxy/)│ └── usr/│ └── local/│ └── bin/│ └── entrypoint├── README.md # Service documentation└── recipe.json # Recipe manifestKey Concepts
- Application Code (
app/): The actual service source code - Docker Infrastructure (
docker/): All container-related files - System Files (
docker/system/app/): Files mirrored into container filesystem - Recipe Manifest (
recipe.json): Defines placeholders, templates, and install steps
The recipe.json Specification
The recipe.json file is the heart of a recipe—it defines how templates are processed and what the recipe produces.
Complete Schema
{ "name": "recipe-name", "title": "Human-Readable Title", "description": "Brief description of what this recipe provides", "version": "1.0",
"features": [ "Feature 1 (displayed to user)", "Feature 2", "Feature 3" ],
"services": [ { "name": "<name>", "description": "Main application" }, { "name": "<name>-worker", "description": "Background worker" }, { "name": "db", "description": "PostgreSQL (shared)" }, { "name": "redis", "description": "Redis (shared)" } ],
"options": { "domain": { "flag": "--domain", "default": "{{SERVICE_NAME}}.localhost", "description": "Custom domain for routing" }, "port": { "flag": "--port", "default": "3000", "description": "Application port" } },
"placeholders": { "SERVICE_NAME": { "source": "name" }, "SERVICE_SLUG": { "source": "name", "transform": "slug" }, "SERVICE_UPPER": { "source": "name", "transform": "upper" }, "DOMAIN": { "source": "option:domain" }, "PORT": { "source": "option:port" } },
"directories": [ "apps/{{SERVICE_NAME}}/src", "docker/compose", "docker/dockerfiles/{{SERVICE_NAME}}", "docker/.config", "docker/system/{{SERVICE_NAME}}" ],
"templates": [ { "from": "app", "to": "apps/{{SERVICE_NAME}}" }, { "from": "docker/compose/service.yml", "to": "docker/compose/{{SERVICE_NAME}}.yml" }, { "from": "docker/compose/service.dev.yml", "to": "docker/compose/{{SERVICE_NAME}}.dev.yml" }, { "from": "docker/dockerfiles/app.Dockerfile", "to": "docker/dockerfiles/{{SERVICE_NAME}}/app" }, { "from": "docker/system/app", "to": "docker/system/{{SERVICE_NAME}}" }, { "from": "config/env.service", "to": "docker/.config/.env.{{SERVICE_NAME}}" } ],
"init_app": { "cwd": "apps", "target_dir": "apps/{{SERVICE_NAME}}", "skip_if_exists": true, "command": "{{INIT_CMD}}" },
"post_install": { "renames": [ { "from": "apps/{{SERVICE_NAME}}/gitignore.template", "to": "apps/{{SERVICE_NAME}}/.gitignore" } ], "chmod": [ { "path": "docker/system/{{SERVICE_NAME}}/usr/local/bin/entrypoint", "mode": "+x" } ], "gitkeep": [ "apps/{{SERVICE_NAME}}/storage/logs" ] },
"next_steps": [ "cd apps/{{SERVICE_NAME}}", "npm install", "make dev s={{SERVICE_NAME}}" ],
"notes": [ "App: http://{{DOMAIN}}", "Health: http://{{DOMAIN}}/api/health" ]}Placeholder Transforms
| Transform | Description | Example |
|---|---|---|
slug | Lowercase with hyphens | my-service |
upper | UPPERCASE with underscores | MY_SERVICE |
lower | lowercase | myservice |
camel | camelCase | myService |
pascal | PascalCase | MyService |
Important Rules
- Placeholders in file content only: Use
{{PLACEHOLDER}}in file contents - Placeholders in paths are expanded: Template paths like
docker/system/{{SERVICE_NAME}}work - No placeholders in actual directory names: The
frompath in templates uses literal names (e.g.,docker/system/app)
CLI-based App Scaffolding (init_app)
For recipes that have an official project scaffolder (Nuxt, Astro, Zola, etc.), prefer generating the base application via the official CLI and then layering MechCrate “overlays” (Docker, configs, small app additions) on top.
How it works
init_appruns beforedirectoriesandtemplatesduringmx add ... --recipe=<name>.- It runs a single shell
command(after placeholder interpolation) incwd. - Idempotence is supported via
skip_if_exists(default true) and an optional--force-initrecipe option that deletesapps/{{SERVICE_NAME}}first.
Recommended option pattern
Add an overridable option and placeholder:
{ "options": { "init_cmd": { "flag": "--init-cmd", "default": "CI=true npx some-cli@latest init {{SERVICE_NAME}} --no-install", "description": "Command to scaffold the app (runs from ./apps)" }, "force_init": { "flag": "--force-init", "default": "false", "description": "Re-run init_cmd by deleting apps/{{SERVICE_NAME}} first (DANGEROUS)" } }, "placeholders": { "INIT_CMD": { "source": "option:init_cmd" } }, "init_app": { "cwd": "apps", "target_dir": "apps/{{SERVICE_NAME}}", "skip_if_exists": true, "command": "{{INIT_CMD}}" }}Best practices
- Use non-interactive flags when available (or set
CI=true) to avoid prompts during scaffolding. - Do not pipe
yesinto interactive TUI prompts; prefer explicit--template/--no-installstyle flags. - Keep
templates/app/...as an overlay, not a full app replacement:- Prefer copying a small set of files (e.g. health endpoint) instead of
app/wholesale. - This ensures the official scaffold remains the canonical baseline.
- Prefer copying a small set of files (e.g. health endpoint) instead of
Docker Structure and Templating
Dockerfile Patterns
Use multi-stage builds to support both development and production:
# ─────────────────────────────────────────────────────────────────────────────# Base Stage# ─────────────────────────────────────────────────────────────────────────────FROM node:20-alpine AS base
WORKDIR /app
# Install system dependenciesRUN apk add --no-cache \ curl \ supervisor \ nginx
# ─────────────────────────────────────────────────────────────────────────────# Dependencies Stage# ─────────────────────────────────────────────────────────────────────────────FROM base AS deps
COPY package*.json ./RUN npm ci
# ─────────────────────────────────────────────────────────────────────────────# Development Stage# ─────────────────────────────────────────────────────────────────────────────FROM base AS development
# Copy dependenciesCOPY --from=deps /app/node_modules ./node_modules
# Copy system files (filesystem mirroring pattern)COPY docker/system/{{SERVICE_NAME}}/ /RUN chmod +x /usr/local/bin/entrypoint
# Development uses bind mounts for source codeEXPOSE 80 3000 5173
ENTRYPOINT ["/usr/local/bin/entrypoint"]
# ─────────────────────────────────────────────────────────────────────────────# Builder Stage# ─────────────────────────────────────────────────────────────────────────────FROM base AS builder
COPY --from=deps /app/node_modules ./node_modulesCOPY apps/{{SERVICE_NAME}} ./
RUN npm run build
# ─────────────────────────────────────────────────────────────────────────────# Production Stage# ─────────────────────────────────────────────────────────────────────────────FROM base AS production
# Copy system files (filesystem mirroring pattern)COPY docker/system/{{SERVICE_NAME}}/ /RUN chmod +x /usr/local/bin/entrypoint
# Copy built applicationCOPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/package.json ./
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/entrypoint"]Build Context
The Dockerfile is built from the project root, not the Docker folder:
services: myapp: build: context: ../.. # Project root dockerfile: docker/dockerfiles/{{SERVICE_NAME}}/app target: productionThis allows the Dockerfile to reference:
docker/system/{{SERVICE_NAME}}/- System filesapps/{{SERVICE_NAME}}/- Application source
System Files and Filesystem Mirroring
The docker/system/[service]/ directory mirrors the container filesystem. A single COPY command overlays all files:
# Copy ALL system files in one commandCOPY docker/system/{{SERVICE_NAME}}/ /Directory Structure
docker/system/app/├── etc/│ ├── supervisor/│ │ ├── supervisord.conf # Main supervisor config│ │ └── conf.d/│ │ ├── stack.dev.conf # Development processes│ │ ├── stack.prod.conf # Production processes│ │ └── queue.conf # Queue worker processes│ └── nginx/│ ├── nginx.conf # Main nginx config│ └── http.d/│ └── app.conf # Application server block└── usr/ └── local/ └── bin/ ├── entrypoint # Container entrypoint └── scheduler-runner # Optional helper scriptsHow It Works
When the container starts:
docker/system/app/etc/supervisor/supervisord.conf→/etc/supervisor/supervisord.confdocker/system/app/etc/nginx/nginx.conf→/etc/nginx/nginx.confdocker/system/app/usr/local/bin/entrypoint→/usr/local/bin/entrypoint
This pattern:
- Simplifies Dockerfile: One
COPYinstead of many - Makes structure obvious: Container layout visible in source
- Enables version control: All configs tracked in git
Supervisor Configuration
Supervisor manages multiple processes within a single container. Use separate config files for different modes.
Main Config (supervisord.conf)
[supervisord]nodaemon=trueuser=rootlogfile=/var/log/supervisor/supervisord.logpidfile=/var/run/supervisord.pidchildlogdir=/var/log/supervisorlogfile_maxbytes=50MBlogfile_backups=10loglevel=info
[unix_http_server]file=/var/run/supervisor.sockchmod=0700
[rpcinterface:supervisor]supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]serverurl=unix:///var/run/supervisor.sock
[include]files = /etc/supervisor/conf.d/*.confDevelopment Stack (stack.dev.conf)
[program:app]command=npm run dev -- --host 0.0.0.0 --port 3000directory=/appuser=nodeautostart=trueautorestart=truestartsecs=5startretries=3stdout_logfile=/dev/stdoutstdout_logfile_maxbytes=0stderr_logfile=/dev/stderrstderr_logfile_maxbytes=0environment=HOME="/app",NODE_ENV="development"
[program:nginx]command=nginx -g "daemon off;"autostart=trueautorestart=truestartsecs=2startretries=3stdout_logfile=/dev/stdoutstdout_logfile_maxbytes=0stderr_logfile=/dev/stderrstderr_logfile_maxbytes=0Production Stack (stack.prod.conf)
[program:app]command=node dist/server.jsdirectory=/appuser=nodeautostart=trueautorestart=truestartsecs=5startretries=3stopwaitsecs=30stdout_logfile=/dev/stdoutstdout_logfile_maxbytes=0stderr_logfile=/dev/stderrstderr_logfile_maxbytes=0environment=HOME="/app",NODE_ENV="production"
[program:nginx]command=nginx -g "daemon off;"autostart=trueautorestart=truestartsecs=2startretries=3stdout_logfile=/dev/stdoutstdout_logfile_maxbytes=0stderr_logfile=/dev/stderrstderr_logfile_maxbytes=0Queue Workers (queue.conf)
[program:worker]process_name=%(program_name)s_%(process_num)02dcommand=node dist/worker.jsdirectory=/appuser=nodeautostart=trueautorestart=truestartsecs=5startretries=3numprocs=%(ENV_WORKER_COUNT)sstdout_logfile=/dev/stdoutstdout_logfile_maxbytes=0stderr_logfile=/dev/stderrstderr_logfile_maxbytes=0environment=HOME="/app"
[program:scheduler]command=/usr/local/bin/scheduler-runnerdirectory=/appuser=nodeautostart=%(ENV_RUN_SCHEDULER)sautorestart=truestartsecs=5startretries=3stdout_logfile=/dev/stdoutstdout_logfile_maxbytes=0stderr_logfile=/dev/stderrstderr_logfile_maxbytes=0environment=HOME="/app"Key Supervisor Features
- Environment Variable Interpolation: Use
%(ENV_VAR_NAME)s - Process Numbering: Use
%(process_num)02dfor multiple workers - Log Streaming: Use
/dev/stdoutandmaxbytes=0for Docker logs - Conditional Autostart: Use
%(ENV_ENABLED)swithtrue/false
Entrypoint Scripts
The entrypoint script handles container initialization, mode switching, and supervisor startup.
Complete Entrypoint Template
#!/bin/shset -e
# ─────────────────────────────────────────────────────────────────────────────# [Service Name] Entrypoint# ─────────────────────────────────────────────────────────────────────────────
APP_MODE="${APP_MODE:-app}"APP_ENV="${APP_ENV:-production}"
# ─────────────────────────────────────────────────────────────────────────────# Environment Defaults# ─────────────────────────────────────────────────────────────────────────────
export WORKER_COUNT="${WORKER_COUNT:-2}"export RUN_SCHEDULER="${RUN_SCHEDULER:-false}"
# ─────────────────────────────────────────────────────────────────────────────# Setup Directories and Permissions# ─────────────────────────────────────────────────────────────────────────────
mkdir -p /var/log/supervisormkdir -p /var/log/nginxmkdir -p /app/storage/logs
chown -R node:node /app/storage 2>/dev/null || truechmod -R 775 /app/storage 2>/dev/null || true
# ─────────────────────────────────────────────────────────────────────────────# Select Supervisor Config Based on Mode# ─────────────────────────────────────────────────────────────────────────────
SUPERVISOR_CONF="/etc/supervisor/conf.d"
case "$APP_MODE" in app) # Main application mode if [ "$APP_ENV" = "local" ] || [ "$APP_ENV" = "development" ]; then echo "Starting in DEVELOPMENT mode..." rm -f "$SUPERVISOR_CONF/stack.prod.conf" "$SUPERVISOR_CONF/queue.conf" 2>/dev/null || true else echo "Starting in PRODUCTION mode..." rm -f "$SUPERVISOR_CONF/stack.dev.conf" "$SUPERVISOR_CONF/queue.conf" 2>/dev/null || true fi ;; worker|queue) # Background worker mode echo "Starting WORKER mode..." rm -f "$SUPERVISOR_CONF/stack.dev.conf" "$SUPERVISOR_CONF/stack.prod.conf" 2>/dev/null || true ;; scheduler) # Scheduler only mode echo "Starting SCHEDULER mode..." export RUN_SCHEDULER="true" rm -f "$SUPERVISOR_CONF/stack.dev.conf" "$SUPERVISOR_CONF/stack.prod.conf" 2>/dev/null || true # Disable workers in scheduler-only mode sed -i 's/autostart=true/autostart=false/' "$SUPERVISOR_CONF/queue.conf" 2>/dev/null || true ;; *) echo "Unknown APP_MODE: $APP_MODE" echo "Valid modes: app, worker, queue, scheduler" exit 1 ;;esac
# ─────────────────────────────────────────────────────────────────────────────# Pre-flight Checks (app mode only)# ─────────────────────────────────────────────────────────────────────────────
if [ "$APP_MODE" = "app" ]; then cd /app
# Wait for database if [ -n "$DB_HOST" ]; then echo "Waiting for database at $DB_HOST:${DB_PORT:-5432}..." timeout=30 while [ $timeout -gt 0 ]; do if nc -z "$DB_HOST" "${DB_PORT:-5432}" 2>/dev/null; then echo "Database is ready" break fi timeout=$((timeout - 1)) sleep 1 done if [ $timeout -eq 0 ]; then echo "Warning: Database connection timeout" fi fi
# Wait for Redis if [ -n "$REDIS_HOST" ]; then echo "Waiting for Redis at $REDIS_HOST:${REDIS_PORT:-6379}..." timeout=30 while [ $timeout -gt 0 ]; do if nc -z "$REDIS_HOST" "${REDIS_PORT:-6379}" 2>/dev/null; then echo "Redis is ready" break fi timeout=$((timeout - 1)) sleep 1 done fi
# Run migrations (optional) if [ "$RUN_MIGRATIONS" = "true" ]; then echo "Running migrations..." npm run migrate 2>/dev/null || true fifi
# ─────────────────────────────────────────────────────────────────────────────# Start Supervisor# ─────────────────────────────────────────────────────────────────────────────
echo "Starting supervisord..."exec /usr/bin/supervisord -c /etc/supervisor/supervisord.confEntrypoint Pattern Summary
- Mode Selection: Use
APP_MODEenv var to select configs - Environment Switching: Use
APP_ENVfor dev/prod behavior - Config Pruning: Remove unused config files at startup
- Dependency Waiting: Wait for databases/caches before starting
- Pre-flight Tasks: Run migrations, cache warming in app mode
- Exec Supervisor: Always
execto replace shell with supervisor
Internal Reverse Proxy
Nginx (For PHP/Static Applications)
Use Nginx when proxying to a single upstream (PHP-FPM, Octane, etc.):
nginx.conf:
user nginx;worker_processes auto;error_log /var/log/nginx/error.log notice;pid /var/run/nginx.pid;
events { worker_connections 1024; multi_accept on; use epoll;}
http { include /etc/nginx/mime.types; default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65;
gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
include /etc/nginx/http.d/*.conf;}http.d/app.conf:
map $http_upgrade $connection_upgrade { default upgrade; '' close;}
server { listen 80; server_name _; root /app/public; index index.php index.html;
charset utf-8;
# Static assets location ~* \.(ico|css|js|gif|jpe?g|png|svg|woff2?|ttf|eot|webp|avif)$ { expires 1y; access_log off; add_header Cache-Control "public, immutable"; try_files $uri =404; }
# Dev server assets (HMR) location /build/ { try_files $uri @vite; }
location @vite { proxy_pass http://127.0.0.1:5173; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; }
# Application location / { try_files $uri $uri/ @app; }
location @app { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; }
# Health check location /up { access_log off; return 200 'OK'; add_header Content-Type text/plain; }}HAProxy (For Multi-Process/Load Balancing)
Use HAProxy when load balancing across multiple backend processes:
haproxy.cfg:
global log stdout format raw local0 info chroot /var/lib/haproxy pidfile /var/run/haproxy.pid maxconn 4000 user haproxy group haproxy daemon
defaults mode http log global option httplog option dontlognull option http-server-close option redispatch retries 3 timeout http-request 10s timeout queue 1m timeout connect 10s timeout client 1m timeout server 1m timeout http-keep-alive 10s timeout check 10s maxconn 3000
frontend main bind *:80 option http-server-close
# Forward headers http-request set-header X-Forwarded-Proto https if { ssl_fc } http-request set-header X-Forwarded-Proto http if !{ ssl_fc }
# Health check endpoint acl is_health path /up use_backend health if is_health
default_backend app
backend app balance roundrobin option httpchk GET /up http-check expect status 200
# Dynamic servers added by entrypoint # server app-00 127.0.0.1:9000 check # server app-01 127.0.0.1:9001 check # ...
backend health http-request return status 200 content-type text/plain string "OK"Dynamic HAProxy Configuration:
In the entrypoint, dynamically generate backend servers:
_writeHAProxyServers() { local num_procs="$1" local port_base="$2" local config_file="/etc/haproxy/conf.d/servers.cfg"
echo "" > "$config_file" for i in $(seq 0 $((num_procs - 1))); do echo " server app-0$i 127.0.0.1:$((port_base + i)) check" >> "$config_file" done}
# In main():export APP_NUM_PROCS="${APP_NUM_PROCS:-4}"_writeHAProxyServers $APP_NUM_PROCS 9000cat /etc/haproxy/cfg/*.cfg > /etc/haproxy/haproxy.cfgWhen to Use Which
| Scenario | Proxy |
|---|---|
| Single app server (Node, Octane, etc.) | Nginx |
| PHP-FPM with static files | Nginx |
| Multiple worker processes | HAProxy |
| WebSocket connections | Both work |
| Need load balancing metrics | HAProxy |
| Simple static + proxy | Nginx |
Environment Variables
Docker Config File (config/env.service)
This file becomes .env.{{SERVICE_NAME}} and is loaded by Docker Compose:
# ─────────────────────────────────────────────────────────────────────────────# {{SERVICE_NAME}} - Container Environment# ─────────────────────────────────────────────────────────────────────────────
# ApplicationAPP_NAME={{SERVICE_NAME}}APP_ENV=productionAPP_DEBUG=falseAPP_URL=https://{{DOMAIN}}APP_VERSION=1.0.0
# DatabaseDB_CONNECTION=pgsqlDB_HOST=dbDB_PORT=5432DB_DATABASE={{SERVICE_NAME}}DB_USERNAME={{SERVICE_NAME}}DB_PASSWORD=
# RedisREDIS_HOST=redisREDIS_PORT=6379REDIS_PASSWORD=
# Cache/QueueCACHE_DRIVER=redisQUEUE_CONNECTION=redisSESSION_DRIVER=redis
# WorkersWORKER_COUNT=2RUN_SCHEDULER=falseEnvironment Variable Categories
- Application Config:
APP_*- Runtime behavior - Database:
DB_*- Connection settings - Cache/Queue:
REDIS_*,CACHE_*,QUEUE_* - Process Control:
WORKER_COUNT,RUN_SCHEDULER - Mode Selection:
APP_MODE,APP_ENV
Supervisor Environment Interpolation
Supervisor reads environment variables with %(ENV_NAME)s:
[program:worker]numprocs=%(ENV_WORKER_COUNT)sautostart=%(ENV_RUN_SCHEDULER)sCompose Files
Network Architecture
MechCrate uses a two-network pattern for services:
-
Default network - Docker Compose’s implicit network for inter-service communication. All services in a compose file (app, db, redis, workers) can talk to each other on this network without explicit configuration.
-
devmesh-traefiknetwork - External shared network for Traefik routing. Only edge-facing services (the main app) join this network. Traefik runs globally on the workstation and routes traffic based on hostname labels.
┌─────────────────────────────────────────────────────────────────┐│ devmesh-traefik network ││ (global, external - shared by all projects) ││ ││ ┌─────────┐ ││ │ Traefik │ ←── Host(`myapp.localhost`) ││ └────┬────┘ ││ │ ││ ┌────▼────┐ ││ │ myapp │ ←── Only this container needs devmesh-traefik ││ └────┬────┘ │└─────────┼───────────────────────────────────────────────────────┘ │┌─────────┼───────────────────────────────────────────────────────┐│ │ default network ││ (implicit, per-project - created by compose) ││ │ ││ ┌────▼────┐ ┌─────────┐ ┌─────────┐ ││ │ myapp │────│ db │────│ redis │ ││ └─────────┘ └─────────┘ └─────────┘ ││ │ ││ ┌────▼────────┐ ┌────────────────┐ ││ │ myapp-worker│────│ myapp-scheduler│ ││ └─────────────┘ └────────────────┘ │└─────────────────────────────────────────────────────────────────┘Production (service.yml)
Five rules the conformance suite enforces on every compose file a recipe ships,
because docker compose config is blind to all five. The first two live in
crates/mx-lib/tests/templates_compose_hygiene.rs, the third in
crates/mx-lib/tests/templates_env_precedence.rs, the fourth in
templates_compose_ports.rs and the fifth in templates_traefik_labels.rs:
- Never set
container_name. Container names are a Docker-daemon-wide namespace, so two projects whose recipes both pindbcannot run at the same time (Conflict. The container name "/db" is already in use). Leave it unset and compose derives<project>-<service>-<index>, unique per project. Service names are whatdepends_on,make sh s=…and Traefik labels address, and those are unchanged. - Only join an
external: truenetwork mx actually creates — that isdevmesh-traefik(mx router up) ormech-network(make init). A network nothing creates renders clean and then fails atupwithnetwork … declared as external, but could not be found. - List
env_filein the documented order:.env.shared, then.env.secrets, then.env.<service>. Compose applies the entries in order and the last one wins, so any other order silently inverts the layering for whichever key is defined twice. A service that reads.env.sharedmust also read.env.secrets; a db-bearing recipe that omits it ships a service whose credentials never arrive. - Never publish a bare host port. A literal
"5432:5432"is a claim on the whole workstation, and the second mx stack to boot dies onBind for 0.0.0.0:5432 failed: port is already allocated. Every host-side publish is${VAR:-<default>}, and the default depends on who dials the port. Tooling dials on demand, so those default to0and Docker allocates a free one:"${DB_HOST_PORT:-0}:5432". A browser dials from a URL the page already holds, so those keep today’s number and gain an override:"${LEPTOS_RELOAD_HOST_PORT:-3001}:3001". The test knows the classification per container port and fails deliberately on one it has never seen, because publishing a host port is a design call rather than a formality. If nothing is listening behind a publish, delete it: three recipes published 24678 for “Vite HMR” that no process ever bound, and holding it stopped sibling stacks booting. - Qualify every Traefik name with the compose project. Traefik keeps one
router table, one service table and one middleware table per provider for the
whole machine, so
traefik.http.routers.api.*is a key every mx project on the workstation shares. Two projects writing it either lose both routers toRouter defined multiple times with different configurationsor, when the labels agree, merge silently into one load-balancer pool that serves each other’s traffic. Writetraefik.http.routers.${COMPOSE_PROJECT_NAME}-{{SERVICE_NAME}}.*, and the same for.services.and.middlewares., definition and reference alike. Compose resolvesCOMPOSE_PROJECT_NAMEfrom the project name it already resolved, so it needs nothing exported. The routing rule’s hostname reads through the environment in the default position,Host(`${{{SERVICE_UPPER}}_ROUTER_HOST:-{{DOMAIN}}}`), so a second stack moves itself with an exported variable instead of an edit to a shipped file.templates/router/is the deliberate exception: it configures Traefik by file, and one machine has one router.
# {{SERVICE_NAME}} - Production Stack
include: - ./db.yml - ./redis.yml
services: {{SERVICE_NAME}}: build: context: ../.. dockerfile: docker/dockerfiles/{{SERVICE_NAME}}/app target: production env_file: # The documented layering, last one winning. Enforced over every compose # file in templates/ by # every_env_file_list_follows_the_documented_precedence_order in # crates/mx-lib/tests/templates_env_precedence.rs. - ../.config/.env.shared - ../.config/.env.secrets - ../.config/.env.{{SERVICE_NAME}} environment: - APP_MODE=app - APP_ENV=production volumes: - ../../apps/{{SERVICE_NAME}}/storage:/app/storage networks: - default - devmesh-traefik depends_on: db: condition: service_healthy redis: condition: service_healthy labels: - traefik.enable=true - traefik.http.routers.${COMPOSE_PROJECT_NAME}-{{SERVICE_NAME}}.rule=Host(`${{{SERVICE_UPPER}}_ROUTER_HOST:-{{DOMAIN}}}`) - traefik.http.routers.${COMPOSE_PROJECT_NAME}-{{SERVICE_NAME}}.entrypoints=web - traefik.http.services.${COMPOSE_PROJECT_NAME}-{{SERVICE_NAME}}.loadbalancer.server.port=80 - traefik.docker.network=devmesh-traefik restart: unless-stopped healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost/up"] interval: 10s timeout: 3s retries: 3 start_period: 15s
{{SERVICE_NAME}}-worker: build: context: ../.. dockerfile: docker/dockerfiles/{{SERVICE_NAME}}/app target: production env_file: - ../.config/.env.shared - ../.config/.env.secrets - ../.config/.env.{{SERVICE_NAME}} environment: - APP_MODE=worker - APP_ENV=production - WORKER_COUNT=2 volumes: - ../../apps/{{SERVICE_NAME}}/storage:/app/storage depends_on: {{SERVICE_NAME}}: condition: service_healthy restart: unless-stopped
networks: devmesh-traefik: external: trueDevelopment Override (service.dev.yml)
# {{SERVICE_NAME}} - Development Overrides# Traefik routes to this service via devmesh-traefik network (no direct port exposure needed)
services: {{SERVICE_NAME}}: build: target: development volumes: - ../../apps/{{SERVICE_NAME}}:/app - /app/node_modules environment: - APP_MODE=app - APP_ENV=local - APP_DEBUG=true ports: # Both ephemeral by default: nginx proxies /build/ and /__vite_hmr to # 127.0.0.1:5173 inside the container, and PHP reaches SSR at 127.0.0.1, # so neither needs a host number until you ask for one. - "${VITE_HOST_PORT:-0}:5173" - "${INERTIA_SSR_HOST_PORT:-0}:13714" healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost/up"] interval: 30s timeout: 5s retries: 3 start_period: 60s
{{SERVICE_NAME}}-worker: build: target: development volumes: - ../../apps/{{SERVICE_NAME}}:/app - /app/node_modules environment: - APP_MODE=worker - APP_ENV=localShared Services (db.yml, redis.yml)
Shared services use the implicit default network—no explicit network configuration needed:
# db.yml - Database service# Uses implicit default network for inter-service communication
services: db: image: postgres:16-alpine env_file: - ../.config/.env.shared - ../.config/.env.secrets - ../.config/.env.db volumes: - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", 'pg_isready -q -U "$${POSTGRES_USER:-postgres}" && psql -U "$${POSTGRES_USER:-postgres}" -d "$${POSTGRES_DB:-postgres}" -tAc "SELECT 1" > /dev/null'] interval: 5s timeout: 3s retries: 5 start_period: 10s restart: unless-stopped
volumes: db_data:Usage
# Start the MechCrate router globally firstmx router up
# Development (access via http://myapp.localhost)docker compose -f docker/compose/myapp.yml -f docker/compose/myapp.dev.yml up
# Or use the mx command from within a MechCrate projectmx dev s=myapp
# Productiondocker compose -f docker/compose/myapp.yml up -dMechCrate Router Integration
MechCrate includes a built-in global Traefik router that enables running multiple projects simultaneously with hostname-based routing.
Prerequisites
Before running any MechCrate service, ensure the global router is running:
# Install and start the MechCrate routermx router installmx router upHow It Works
- Global Traefik runs on the workstation, binding ports 80 and 443
- External Network
devmesh-traefikis created once and shared by all projects - Labels on containers tell Traefik how to route traffic based on hostname
- No Port Conflicts - all services accessed via hostname, not port numbers
Required Labels
Every edge-facing service needs these Traefik labels:
labels: - traefik.enable=true - traefik.http.routers.${COMPOSE_PROJECT_NAME}-{{SERVICE_NAME}}.rule=Host(`${{{SERVICE_UPPER}}_ROUTER_HOST:-{{DOMAIN}}}`) - traefik.http.routers.${COMPOSE_PROJECT_NAME}-{{SERVICE_NAME}}.entrypoints=web - traefik.http.services.${COMPOSE_PROJECT_NAME}-{{SERVICE_NAME}}.loadbalancer.server.port=80 - traefik.docker.network=devmesh-traefik| Label | Purpose |
|---|---|
traefik.enable=true | Enable Traefik routing for this container |
traefik.http.routers.${COMPOSE_PROJECT_NAME}-<name>.rule | Hostname routing rule |
traefik.http.routers.${COMPOSE_PROJECT_NAME}-<name>.entrypoints | Which ports to listen on (web=80, websecure=443) |
traefik.http.services.${COMPOSE_PROJECT_NAME}-<name>.loadbalancer.server.port | Container’s internal port |
traefik.docker.network | Required - tells Traefik which network to use |
Two things in that block are not cosmetic:
${COMPOSE_PROJECT_NAME}-prefixes the router, service and middleware names. One Traefik instance serves the whole workstation from one table of each, so a bare name is shared with every other mx project. Compose resolves the variable from the project name it already resolved, so nothing has to be exported and it holds under a hand-rolleddocker compose -f …too. A router in the dashboard readsmyproj-{{SERVICE_NAME}}@docker.- The Host rule reads through
{{SERVICE_UPPER}}_ROUTER_HOSTwith the domain in the default position. Unique names stop two projects clobbering each other’s router entries, but they do not stop two projects claiming one hostname: Traefik accepts both and serves one. The default keeps every existing URL working, and a second stack moves itself with{{SERVICE_UPPER}}_ROUTER_HOST=other.localhost make dev. The variable is_ROUTER_HOSTrather than_HOSTbecauseDB_HOST,REDIS_HOSTandAPI_HOSTare conventional service-address variables a developer may already export, and quietly repointing a router at a database host is not a failure anyone would debug.
Network Configuration
Only the main application service needs the devmesh-traefik network:
services: myapp: networks: - default # For talking to db, redis, workers - devmesh-traefik # For Traefik routing
myapp-worker: # No devmesh-traefik needed - workers don't receive external traffic # Uses implicit default network
networks: devmesh-traefik: external: trueAccessing Services
With devmesh-traefik running, access services by hostname:
| Service | URL |
|---|---|
| Main app | http://myapp.localhost |
| API service | http://api.localhost |
| Admin panel | http://admin.localhost |
| Traefik dashboard | http://localhost:<dashboard-port> (run mx router inspect) |
HTTPS Support
For HTTPS, add certificates to ~/.devmesh-traefik/letsencrypt/ and update the entrypoint:
labels: - traefik.http.routers.${COMPOSE_PROJECT_NAME}-{{SERVICE_NAME}}.entrypoints=websecure - traefik.http.routers.${COMPOSE_PROJECT_NAME}-{{SERVICE_NAME}}.tls=trueRouter Commands
| Command | Description |
|---|---|
mx router install | First-time setup - install router to ~/.mech-crate/router |
mx router up | Start or update the router |
mx router down | Stop the router |
mx router status | Show router container status |
mx router logs | Tail router logs |
mx router inspect | Show dashboard URL and connected services |
mx router reload | Hot-reload config without restart |
mx router uninstall | Remove router installation |
Troubleshooting
| Issue | Solution |
|---|---|
| Service not reachable | Check mx router status - is Traefik running? |
| 404 errors | Verify traefik.docker.network=devmesh-traefik label is set |
| Container not visible to Traefik | Ensure container is on devmesh-traefik network |
| Port conflicts | Remove direct port mappings - let Traefik handle routing |
| Dashboard URL | Run mx router inspect to see the dashboard port |
Best Practices
1. Dockerfile Organization
- Use multi-stage builds (base → deps → dev/build → prod)
- Install supervisor and proxy in base stage
- Copy system files with single
COPYcommand - Set
ENTRYPOINTto your entrypoint script
2. System Files
- Mirror exact container filesystem structure
- Use separate supervisor configs per mode
- Include all needed scripts in
usr/local/bin/ - Set executable permissions in Dockerfile
3. Entrypoint Design
- Always use
set -eat the top - Export environment defaults early
- Create directories and set permissions
- Remove unused supervisor configs based on mode
- Wait for dependencies before starting
- Use
execfor final command
4. Supervisor
- Stream logs to stdout/stderr for Docker
- Use environment interpolation for dynamic config
- Set appropriate
startsecsandstartretries - Use
stopwaitsecsfor graceful shutdown
5. Reverse Proxy
- Nginx for simple proxy + static serving
- HAProxy for load balancing multiple processes
- Always include health check endpoints
- Forward appropriate headers (X-Forwarded-*)
6. Environment Variables
- Use consistent naming (
DB_*,REDIS_*, etc.) - Provide sensible defaults in entrypoint
- Keep secrets out of Dockerfiles
- Use
.envfiles for compose
7. Compose Files
- Use build targets to switch dev/prod
- Volume mount source code in dev only
- Include healthchecks for orchestration
- Use service dependencies appropriately
8. Networking
- Use implicit default network for inter-service communication (db, redis, workers)
- Only edge-facing services join
devmesh-traefiknetwork - Never define per-service internal networks (Docker handles this automatically)
- Always include
traefik.docker.network=devmesh-traefiklabel - Don’t expose port 80 directly in dev—let Traefik handle routing
- Keep HMR/debug ports (5173, 13714) exposed for development tooling
Checklist for New Recipes
-
recipe.jsonwith all placeholders -
app/with complete application code -
docker/dockerfiles/app.Dockerfilewith multi-stage build -
docker/system/app/with filesystem mirror:-
etc/supervisor/supervisord.conf -
etc/supervisor/conf.d/stack.dev.conf -
etc/supervisor/conf.d/stack.prod.conf -
etc/nginx/oretc/haproxy/configs -
usr/local/bin/entrypoint
-
-
docker/compose/service.ymlfor production:- Uses
include:for db.yml, redis.yml - Edge service has
networks: [default, devmesh-traefik] - Traefik labels with
traefik.docker.network=devmesh-traefik - Workers/schedulers use implicit default network only
- No per-service internal networks defined
- Uses
-
docker/compose/service.dev.ymlfor development:- No direct port 80 mapping (Traefik handles routing)
- HMR ports exposed (5173, 13714 etc.)
-
config/env.serviceenvironment template -
README.mdwith setup instructions - Health check endpoint in application
- Test with
mx router up && mx add <name> --recipe=<recipe>
End of Recipe Authoring Guide