Skip to content

Recipe Authoring Guide

A comprehensive guide for creating MechCrate recipes—reusable application templates with Docker orchestration, process management, and reverse proxying.

Table of Contents

  1. Recipe Structure Overview
  2. The recipe.json Specification
  3. Docker Structure and Templating
  4. System Files and Filesystem Mirroring
  5. Supervisor Configuration
  6. Entrypoint Scripts
  7. Internal Reverse Proxy
  8. Environment Variables
  9. Compose Files
  10. Devmesh-Traefik Integration
  11. 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 manifest

Key Concepts

  1. Application Code (app/): The actual service source code
  2. Docker Infrastructure (docker/): All container-related files
  3. System Files (docker/system/app/): Files mirrored into container filesystem
  4. 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

TransformDescriptionExample
slugLowercase with hyphensmy-service
upperUPPERCASE with underscoresMY_SERVICE
lowerlowercasemyservice
camelcamelCasemyService
pascalPascalCaseMyService

Important Rules

  1. Placeholders in file content only: Use {{PLACEHOLDER}} in file contents
  2. Placeholders in paths are expanded: Template paths like docker/system/{{SERVICE_NAME}} work
  3. No placeholders in actual directory names: The from path 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_app runs before directories and templates during mx add ... --recipe=<name>.
  • It runs a single shell command (after placeholder interpolation) in cwd.
  • Idempotence is supported via skip_if_exists (default true) and an optional --force-init recipe option that deletes apps/{{SERVICE_NAME}} first.

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 yes into interactive TUI prompts; prefer explicit --template / --no-install style 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.

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 dependencies
RUN 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 dependencies
COPY --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 code
EXPOSE 80 3000 5173
ENTRYPOINT ["/usr/local/bin/entrypoint"]
# ─────────────────────────────────────────────────────────────────────────────
# Builder Stage
# ─────────────────────────────────────────────────────────────────────────────
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY 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 application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --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:

docker/compose/service.yml
services:
myapp:
build:
context: ../.. # Project root
dockerfile: docker/dockerfiles/{{SERVICE_NAME}}/app
target: production

This allows the Dockerfile to reference:

  • docker/system/{{SERVICE_NAME}}/ - System files
  • apps/{{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 command
COPY 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 scripts

How It Works

When the container starts:

  • docker/system/app/etc/supervisor/supervisord.conf → /etc/supervisor/supervisord.conf
  • docker/system/app/etc/nginx/nginx.conf → /etc/nginx/nginx.conf
  • docker/system/app/usr/local/bin/entrypoint → /usr/local/bin/entrypoint

This pattern:

  1. Simplifies Dockerfile: One COPY instead of many
  2. Makes structure obvious: Container layout visible in source
  3. 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=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
childlogdir=/var/log/supervisor
logfile_maxbytes=50MB
logfile_backups=10
loglevel=info
[unix_http_server]
file=/var/run/supervisor.sock
chmod=0700
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock
[include]
files = /etc/supervisor/conf.d/*.conf

Development Stack (stack.dev.conf)

[program:app]
command=npm run dev -- --host 0.0.0.0 --port 3000
directory=/app
user=node
autostart=true
autorestart=true
startsecs=5
startretries=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=HOME="/app",NODE_ENV="development"
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
startsecs=2
startretries=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

Production Stack (stack.prod.conf)

[program:app]
command=node dist/server.js
directory=/app
user=node
autostart=true
autorestart=true
startsecs=5
startretries=3
stopwaitsecs=30
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=HOME="/app",NODE_ENV="production"
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
startsecs=2
startretries=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

Queue Workers (queue.conf)

[program:worker]
process_name=%(program_name)s_%(process_num)02d
command=node dist/worker.js
directory=/app
user=node
autostart=true
autorestart=true
startsecs=5
startretries=3
numprocs=%(ENV_WORKER_COUNT)s
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=HOME="/app"
[program:scheduler]
command=/usr/local/bin/scheduler-runner
directory=/app
user=node
autostart=%(ENV_RUN_SCHEDULER)s
autorestart=true
startsecs=5
startretries=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=HOME="/app"

Key Supervisor Features

  1. Environment Variable Interpolation: Use %(ENV_VAR_NAME)s
  2. Process Numbering: Use %(process_num)02d for multiple workers
  3. Log Streaming: Use /dev/stdout and maxbytes=0 for Docker logs
  4. Conditional Autostart: Use %(ENV_ENABLED)s with true/false

Entrypoint Scripts

The entrypoint script handles container initialization, mode switching, and supervisor startup.

Complete Entrypoint Template

#!/bin/sh
set -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/supervisor
mkdir -p /var/log/nginx
mkdir -p /app/storage/logs
chown -R node:node /app/storage 2>/dev/null || true
chmod -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
fi
fi
# ─────────────────────────────────────────────────────────────────────────────
# Start Supervisor
# ─────────────────────────────────────────────────────────────────────────────
echo "Starting supervisord..."
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf

Entrypoint Pattern Summary

  1. Mode Selection: Use APP_MODE env var to select configs
  2. Environment Switching: Use APP_ENV for dev/prod behavior
  3. Config Pruning: Remove unused config files at startup
  4. Dependency Waiting: Wait for databases/caches before starting
  5. Pre-flight Tasks: Run migrations, cache warming in app mode
  6. Exec Supervisor: Always exec to 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:

Terminal window
_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 9000
cat /etc/haproxy/cfg/*.cfg > /etc/haproxy/haproxy.cfg

When to Use Which

ScenarioProxy
Single app server (Node, Octane, etc.)Nginx
PHP-FPM with static filesNginx
Multiple worker processesHAProxy
WebSocket connectionsBoth work
Need load balancing metricsHAProxy
Simple static + proxyNginx

Environment Variables

Docker Config File (config/env.service)

This file becomes .env.{{SERVICE_NAME}} and is loaded by Docker Compose:

Terminal window
# ─────────────────────────────────────────────────────────────────────────────
# {{SERVICE_NAME}} - Container Environment
# ─────────────────────────────────────────────────────────────────────────────
# Application
APP_NAME={{SERVICE_NAME}}
APP_ENV=production
APP_DEBUG=false
APP_URL=https://{{DOMAIN}}
APP_VERSION=1.0.0
# Database
DB_CONNECTION=pgsql
DB_HOST=db
DB_PORT=5432
DB_DATABASE={{SERVICE_NAME}}
DB_USERNAME={{SERVICE_NAME}}
DB_PASSWORD=
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
# Cache/Queue
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
# Workers
WORKER_COUNT=2
RUN_SCHEDULER=false

Environment Variable Categories

  1. Application Config: APP_* - Runtime behavior
  2. Database: DB_* - Connection settings
  3. Cache/Queue: REDIS_*, CACHE_*, QUEUE_*
  4. Process Control: WORKER_COUNT, RUN_SCHEDULER
  5. Mode Selection: APP_MODE, APP_ENV

Supervisor Environment Interpolation

Supervisor reads environment variables with %(ENV_NAME)s:

[program:worker]
numprocs=%(ENV_WORKER_COUNT)s
autostart=%(ENV_RUN_SCHEDULER)s

Compose Files

Network Architecture

MechCrate uses a two-network pattern for services:

  1. 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.

  2. devmesh-traefik network - 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 pin db cannot 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 what depends_on, make sh s=… and Traefik labels address, and those are unchanged.
  • Only join an external: true network mx actually creates — that is devmesh-traefik (mx router up) or mech-network (make init). A network nothing creates renders clean and then fails at up with network … declared as external, but could not be found.
  • List env_file in 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.shared must 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 on Bind 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 to 0 and 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 to Router defined multiple times with different configurations or, when the labels agree, merge silently into one load-balancer pool that serves each other’s traffic. Write traefik.http.routers.${COMPOSE_PROJECT_NAME}-{{SERVICE_NAME}}.*, and the same for .services. and .middlewares., definition and reference alike. Compose resolves COMPOSE_PROJECT_NAME from 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: true

Development 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=local

Shared 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

Terminal window
# Start the MechCrate router globally first
mx 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 project
mx dev s=myapp
# Production
docker compose -f docker/compose/myapp.yml up -d

MechCrate 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:

Terminal window
# Install and start the MechCrate router
mx router install
mx router up

How It Works

  1. Global Traefik runs on the workstation, binding ports 80 and 443
  2. External Network devmesh-traefik is created once and shared by all projects
  3. Labels on containers tell Traefik how to route traffic based on hostname
  4. 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
LabelPurpose
traefik.enable=trueEnable Traefik routing for this container
traefik.http.routers.${COMPOSE_PROJECT_NAME}-<name>.ruleHostname routing rule
traefik.http.routers.${COMPOSE_PROJECT_NAME}-<name>.entrypointsWhich ports to listen on (web=80, websecure=443)
traefik.http.services.${COMPOSE_PROJECT_NAME}-<name>.loadbalancer.server.portContainer’s internal port
traefik.docker.networkRequired - 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-rolled docker compose -f … too. A router in the dashboard reads myproj-{{SERVICE_NAME}}@docker.
  • The Host rule reads through {{SERVICE_UPPER}}_ROUTER_HOST with 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_HOST rather than _HOST because DB_HOST, REDIS_HOST and API_HOST are 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: true

Accessing Services

With devmesh-traefik running, access services by hostname:

ServiceURL
Main apphttp://myapp.localhost
API servicehttp://api.localhost
Admin panelhttp://admin.localhost
Traefik dashboardhttp://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=true

Router Commands

CommandDescription
mx router installFirst-time setup - install router to ~/.mech-crate/router
mx router upStart or update the router
mx router downStop the router
mx router statusShow router container status
mx router logsTail router logs
mx router inspectShow dashboard URL and connected services
mx router reloadHot-reload config without restart
mx router uninstallRemove router installation

Troubleshooting

IssueSolution
Service not reachableCheck mx router status - is Traefik running?
404 errorsVerify traefik.docker.network=devmesh-traefik label is set
Container not visible to TraefikEnsure container is on devmesh-traefik network
Port conflictsRemove direct port mappings - let Traefik handle routing
Dashboard URLRun 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 COPY command
  • Set ENTRYPOINT to 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 -e at the top
  • Export environment defaults early
  • Create directories and set permissions
  • Remove unused supervisor configs based on mode
  • Wait for dependencies before starting
  • Use exec for final command

4. Supervisor

  • Stream logs to stdout/stderr for Docker
  • Use environment interpolation for dynamic config
  • Set appropriate startsecs and startretries
  • Use stopwaitsecs for 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 .env files 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-traefik network
  • Never define per-service internal networks (Docker handles this automatically)
  • Always include traefik.docker.network=devmesh-traefik label
  • 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.json with all placeholders
  • app/ with complete application code
  • docker/dockerfiles/app.Dockerfile with 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/ or etc/haproxy/ configs
    • usr/local/bin/entrypoint
  • docker/compose/service.yml for 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
  • docker/compose/service.dev.yml for development:
    • No direct port 80 mapping (Traefik handles routing)
    • HMR ports exposed (5173, 13714 etc.)
  • config/env.service environment template
  • README.md with setup instructions
  • Health check endpoint in application
  • Test with mx router up && mx add <name> --recipe=<recipe>

End of Recipe Authoring Guide