Exhaustive reference for every field in mdp.yaml. For the narrative guide with more worked examples, see Config.
At a glance
port_range: "10000-60000"
global:
env_file: ./.mdp.env
env:
API_URL: "http://localhost:${api.port}"
services:
web:
command: npm run dev
dir: ./frontend
proxy: 3000
env:
API_URL: "http://localhost:${api.port}"
api:
command: go run ./cmd/server
dir: ./backend
proxy: 4000
depends_on: [db]
env:
DATABASE_URL: "postgres://localhost:${db.port}/app"
db:
command: docker compose up db --wait
# no proxy — DB is internal only
Top-level
| Key | Type | Default | Notes |
|---|---|---|---|
services |
map of name → service | {} |
Each key is a service name. Shown in the TUI and referenced by depends_on. |
port_range |
"MIN-MAX" string |
"10000-60000" |
Range for auto-allocating service ports (when port is 0 / unset). |
global |
global | {} |
Project-wide env export block. |
port_range: "20000-25000" # narrow range if you want predictable ports for firewall rules
services:
web:
command: npm run dev
proxy: 3000
api:
command: ./api
proxy: 4000
global
| Key | Type | Default | Notes |
|---|---|---|---|
global.env_file |
string path | "" (none) |
Aggregate .env file to write at startup. Relative paths resolve against the mdp.yaml directory. ~ is expanded. Empty = no file written. |
global.env |
map of name → scalar | {ref: …, default: …} |
{} |
Env vars to write to env_file. Scalar values support ${svc.port} / ${svc.env.VAR} / ${@repo.svc...} interpolation with optional :-default fallback. The mapping form takes a ref: (e.g. svc.port, @repo.svc.port) and an optional default: used when the ref cannot be resolved. |
Writing an aggregate .env for external tools
global:
env_file: ./.mdp.env
env:
# Scalar form — any string, with ${...} interpolation.
API_URL: "http://localhost:${api.port}"
DB_URL: "postgres://app:app@localhost:${db.env.DB_PORT}/app"
# Mapping form — pass another service's port or env var through as-is.
API_PORT:
ref: api.port
DB_PORT:
ref: db.env.DB_PORT
After resolution .mdp.env contains:
# Generated by mdp — do not edit. Overwritten on start.
API_PORT="42301"
API_URL="http://localhost:42301"
DB_PORT="20045"
DB_URL="postgres://app:app@localhost:20045/app"
Keys are sorted alphabetically and values are double-quoted so the file is safe to source from a shell or load with standard .env parsers.
The file is written after all ports are allocated and before any service command runs. Point your editor's run config or shell tooling at it when you need the same values outside mdp.
Service
Keys under services.<name>.
| Key | Type | Default | Notes |
|---|---|---|---|
command |
string | "" |
Shell command to run. If empty and port > 0, the service is treated as externally managed: registered with the proxy but not started by mdp. |
setup |
list of strings | [] |
Commands run sequentially before command. First non-zero exit fails the service and command is not started. Shares dir and env. |
shutdown |
list of strings | [] |
Commands run sequentially after command exits (for any reason). Best-effort with a 30s per-step timeout. Shares dir and env. |
dir |
string path | "" |
Working directory for command, setup, and shutdown. Relative paths resolve against the mdp.yaml directory. ~ is expanded. |
port |
int | 0 |
Fixed upstream port (for externally managed processes like Docker containers). When 0, a free port is allocated from port_range. Ignored when ports: is set. |
proxy |
int | 0 |
Proxy port to register this service on. 0 means do not register with any proxy — useful for DB-only services. |
group |
string | detected git branch in batch mode, else "" |
Logical grouping. Displayed as <group>/<name> in the TUI and used for cookie-based switching. |
scheme |
"http" | "https" |
"http" (auto "https" when tls_cert is set) |
Scheme the proxy uses to connect to this service's upstream. |
tls_cert |
string path | "" |
TLS certificate path. Relative paths resolve against the mdp.yaml directory. ~ is expanded. Setting this auto-infers scheme: https. |
tls_key |
string path | "" |
TLS key path. Paired with tls_cert. |
env_file |
string path | "" |
Path to write this service's resolved env vars as a .env file. Relative paths resolve against the service's dir if set, else the mdp.yaml directory. ~ is expanded. |
env |
map of name → scalar | {ref: …, default: …} |
{} |
Env vars for command, setup, and shutdown. Scalar values support: literal strings, auto (allocates a port in the corresponding ports[] entry), ${svc.port} / ${svc.NAMED_PORT} (own-mdp.yaml port references), ${@repo.svc.port} / ${@repo.svc.env.VAR} (cross-repo lookups via the orchestrator) with optional :-default fallback. The mapping form takes a ref: (e.g. api.port, @backend.api.env.URL) plus an optional default:. Cross-repo refs without a default are silently omitted when the peer is not registered. |
ports |
list of port mapping | [] |
Multi-port mode. When present, port is ignored and ports are allocated per entry. |
log_split |
string | mapping | "" |
Demultiplex combined-stream logs into per-sub-service colored lanes. Accepts the scalar "compose" (built-in docker-compose parser) or a mapping { regex: '<pattern>' } for arbitrary prefixes. See log_split. |
depends_on |
list of service names | [] |
Wait for each dependency to be TCP-reachable on its assigned port(s) before starting. 60s per-dependency timeout. Unknown names and cycles are rejected at config load. |
health_check |
health check | "docker" |
nil (TCP on port) |
Liveness probe used by the registry pruner. When unset, the default is a TCP dial of the service's registered port. See Detached services and health checks. |
command — the basic case
services:
web:
command: npm run dev
dir: ./frontend
proxy: 3000
command omitted — register an externally managed process
When command is empty and port is set, mdp only registers the upstream with the proxy. You start the actual process however you like (Docker, systemd, another terminal).
services:
auth:
port: 8080 # already running somewhere on :8080
proxy: 5000 # expose it through the proxy on :5000
setup and shutdown hooks
services:
web:
setup:
- bun install
- bun run build:assets
command: bun dev
shutdown:
- rm -rf .cache/dev
dir: ./frontend
proxy: 3000
If bun install exits non-zero, web is marked failed and bun dev is never started. shutdown runs whenever command exits — clean exit, crash, or Ctrl-C.
port — fixed vs auto-allocated
services:
api:
command: ./api
proxy: 4000
# no `port:` → mdp picks a free one from port_range and passes it as $PORT
legacy:
command: ./legacy-server --bind=:9000
port: 9000 # legacy-server can't read $PORT, so pin it
proxy: 4100
proxy: 0 — internal-only services
services:
db:
command: docker compose up db --wait
# proxy omitted → the DB is allocated a free port but is NOT exposed
# through a proxy listener. Other services reference it via ${db.port}.
group — organise multiple branches or repos
Usually group is auto-detected from the git branch in batch mode, but you can set it explicitly when running unrelated projects side-by-side:
services:
web-main:
command: npm run dev
dir: ../main/web
proxy: 3000
group: main
web-feature:
command: npm run dev
dir: ../feature-x/web
proxy: 3000
group: feature-x
Both services register on proxy :3000; the widget / TUI switches between main/web-main and feature-x/web-feature.
scheme, tls_cert, tls_key — HTTPS upstream
services:
web:
command: npm run dev -- --https
proxy: 3000
tls_cert: ./certs/localhost.pem
tls_key: ./certs/localhost-key.pem
# scheme auto-becomes "https" — no need to set it
env_file — write one service's env to a dotfile
services:
api:
command: ./api
dir: ./backend
proxy: 4000
env_file: .mdp.env # resolves to ./backend/.mdp.env
env:
DATABASE_URL: "postgres://localhost:${db.port}/app"
The file ends up containing exactly what the api process sees:
# Generated by mdp — do not edit. Overwritten on start.
DATABASE_URL="postgres://localhost:42044/app"
PORT="20017"
env — literals, interpolation, and auto
services:
api:
command: ./api
proxy: 4000
env:
LOG_LEVEL: debug # literal
API_URL: "http://localhost:${api.port}" # self-reference
DB_URL: "postgres://localhost:${db.port}" # cross-service port
To read another service's env var, export it through global.env — service-level env values cannot use ${svc.env.VAR}.
auto is only meaningful inside multi-port services:
services:
infra:
command: docker compose up --wait
env:
API_PORT: auto # mdp assigns a free port and writes it here
DB_PORT: auto
ports:
- env: API_PORT
proxy: 4000
- env: DB_PORT
ports — multi-port services
Use this when one command exposes several ports and you want to map (or not map) each one to a proxy individually:
services:
infra:
command: docker compose up --wait
env:
API_PORT: auto
DB_PORT: auto
ports:
- env: API_PORT
proxy: 4000 # HTTP — registered with the 4000 proxy
- env: DB_PORT # no proxy — DB port is internal only
Every proxy-bearing port of a multi-port service registers under the parent service's key (<group>/<service>). The registered entry's env map carries all of the service's resolved env vars, so cross-repo refs select a specific port by env-var name: @<repo>.<service>.env.<ENV_VAR>. The bare form @<repo>.<service>.port is ambiguous when a service has more than one proxy-bearing port and will be rejected with a 409 — use the .env.<KEY> form. Two ports of the same service cannot share a proxy: value (the second would silently overwrite the first; mdp rejects the config at load time).
Other services reference the named ports as ${svc.NAME}, where NAME is the port mapping's env key:
services:
web:
command: npm run dev
proxy: 3000
env:
API_URL: "http://localhost:${infra.API_PORT}"
DB_URL: "postgres://localhost:${infra.DB_PORT}/app"
depends_on: [infra]
log_split — demultiplex combined-stream logs
When a service's command produces output from multiple sub-processes over a single stream (e.g. docker compose up), log_split parses each line and routes it to its own colored lane so each sub-process gets its own prefix. Two forms are accepted:
Built-in compose mode (scalar shorthand) — parses docker-compose's <name> | <message> format, including colorized output (--ansi=always / TTY).
services:
infra:
command: docker compose up
log_split: compose
env:
API_PORT: auto
AUTH_PORT: auto
ports:
- env: API_PORT
proxy: 4000
- env: AUTH_PORT
proxy: 5000
Custom regex mode (mapping form) — provide any Go-regexp pattern with named captures name and msg. Useful for kubectl, honcho/foreman, bracket-prefixed tools, or anything else that multiplexes logs over one stream.
services:
procfile:
command: honcho start
log_split:
regex: '^(?P<name>[a-z]+)\s*\|\s(?P<msg>.*)$'
k8s-app:
command: kubectl logs --all-containers --prefix -f pod/api
log_split:
regex: '^\[pod/(?P<name>[^/]+)/[^\]]+\]\s*(?P<msg>.*)$'
Lines that don't match the parser fall through to the outer service prefix, so top-level status output (compose's Attaching to…, kubectl's reconnect messages, etc.) still appears under the service name. Stdout and stderr share the same name-to-color map so a sub-process's two streams collapse into one lane. Inside mdp.yaml, sub-lane labels are prefixed with the service name as <service>/<sub> so it's obvious which service an inner lane belongs to. Lane labels render at full length — short labels are right-padded to 12 characters for column alignment, longer labels expand past that and push their line out rather than being truncated.
For ad-hoc commands, pass the same value as a flag:
mdp run --log-split=compose -- docker compose up
mdp run --log-split='regex:^\[(?P<name>[^\]]+)\]\s*(?P<msg>.*)$' -- some-prefixed-tool
depends_on — wait for readiness
services:
db:
command: docker compose up db --wait
env:
DB_PORT: auto
ports:
- env: DB_PORT
api:
command: ./api
proxy: 4000
depends_on: [db]
web:
command: npm run dev
proxy: 3000
depends_on: [api, db]
Services without depends_on start in parallel. Independent branches of the dependency graph also run in parallel — only direct dependents wait. Each dependency has a 60s TCP-readiness timeout. Failed deps cause dependents to be marked failed and skipped. Cycles and references to undefined services are rejected at config load.
Detached services and health checks
By default, mdp prunes a service from its proxy when the command process exits. That's wrong for detached commands like docker compose up -d: the foreground process exits quickly, but the real service is still listening on its port.
Instead, once the process exits mdp falls back to a liveness probe and keeps the entry around until the probe fails for ~30 seconds. The default probe is a TCP dial of the service's port. Override it with health_check:
services:
# Default — TCP probe on svc.port.
web:
command: npm run dev
proxy: 3000
# Custom HTTP probe.
api:
command: bun run dev
proxy: 4000
health_check:
http: http://localhost:4000/health
# Shorthand for docker compose: runs `docker compose ps -q` in `dir`,
# healthy as long as at least one container in the project is running.
db:
command: docker compose up -d
dir: ./db
port: 5432
health_check: docker
# For compose projects whose services are all short-lived, probe the
# network directly instead.
workers:
command: docker compose up -d
dir: ./workers
port: 6000
health_check:
command: "docker network inspect workers_default"
Variants (mutually exclusive):
| Field | Healthy when |
|---|---|
tcp: <port> |
a TCP connection to localhost:<port> succeeds |
http: <url> |
an HTTP GET returns a 2xx or 3xx status |
command: <shell tokens> |
the command exits 0 (run in the service's dir) |
shorthand docker |
docker compose ps -q in the service's dir exits 0 with non-empty output |
Command parsing honors single/double quotes but does not invoke a shell, so write sh -c "..." explicitly if you need shell features. Timeouts: TCP 2s, HTTP 3s, command/docker 5s.
Port mapping
Entries in services.<name>.ports[].
| Key | Type | Required | Notes |
|---|---|---|---|
env |
string | yes | Env var name that will hold the allocated port. Reference from other services' env with ${svc.NAME}, or from global.env with ${svc.env.NAME}. |
proxy |
int | no (default 0) |
Proxy port to register this port on. 0 = no proxy (use for DB / non-HTTP ports). Two ports of the same service cannot share a proxy: value. |
protocol |
string | no (default tcp) |
Transport protocol. udp marks the port as UDP: allocation uses a UDP-aware free-port check, and the depends_on readiness probe skips it (TCP probes never succeed on UDP). Incompatible with proxy. |
services:
infra:
command: docker compose up --wait
env:
API_PORT: auto
WS_PORT: auto
DB_PORT: auto
JAEGER_AGENT_PORT: auto
ports:
- env: API_PORT
proxy: 4000
- env: WS_PORT
proxy: 4001
- env: DB_PORT # internal only, no proxy
- env: JAEGER_AGENT_PORT
protocol: udp # UDP-only; compose publishes "${JAEGER_AGENT_PORT}:6831/udp"
In the TUI this service appears as <group>/infra on both the 4000 and 4001 proxies — the parent service key is the registered identity. Internal-only ports (no proxy:) and UDP mappings are not registered against any proxy and do not appear in the TUI.
Interpolation
Service-level env values support port references only:
${svc.port}— the primary allocated port of the service namedsvc(single-port services).${svc.NAME}— a named port from a multi-port service'sports[]entry, whereNAMEis that entry'senvkey (e.g.${infra.API_PORT}).
global.env scalar values support everything above, plus:
${svc.env.VAR}— the resolved value of another service's env var.
Using ${svc.env.VAR} inside a service-level env is a startup error ("env-var references are not allowed here"). All references resolve after port allocation and before any command runs, so you can freely cross-reference services without worrying about start order.
services:
api:
command: ./api
proxy: 4000
infra:
command: docker compose up --wait
env:
GRPC_PORT: auto
ports:
- env: GRPC_PORT
web:
command: npm run dev
proxy: 3000
env:
API_URL: "http://localhost:${api.port}" # single-port primary
GRPC_URL: "localhost:${infra.GRPC_PORT}" # multi-port named
depends_on: [api, infra]
global:
env_file: ./.mdp.env
env:
# global.env adds ${svc.env.VAR} — the resolved value of another service's env var.
ADMIN_TOKEN: "${api.env.ADMIN_TOKEN}"
ref: mapping form
Scalar strings work in any env. The ref: mapping form passes the referenced value through without string-wrapping. It works in both global.env and per-service env:
global:
env_file: ./.mdp.env
env:
# Scalar — wraps the value into a string template.
API_URL: "http://localhost:${api.port}"
# Ref — passes the value through on its own.
API_PORT:
ref: api.port
ADMIN_TOKEN:
ref: api.env.ADMIN_TOKEN
Cross-repo @<repo> references
mdp run instances in different repos all register with the same singleton orchestrator. To reference a service running in another repo, prefix the reference with @<repo-name>.. The lookup is scoped to the same group as the caller by default (typically the git branch); use --link to override the lookup group per peer repo (see below).
# In frontend's mdp.yaml
services:
web:
command: npm run dev
env:
# Interpolation form with optional :-default fallback.
API_URL: "http://localhost:${@backend.api.port:-3001}"
# Ref form with optional default: field.
AUTH_TOKEN:
ref: "@backend.api.env.AUTH_TOKEN"
default: "dev-token"
Resolution semantics:
- The frontend resolves
@backend.*against the orchestrator at startup. Whatever the backend is registered as (port, exposed env vars), the frontend gets — for the same group. - If the peer is unresolved AND a default is provided, the default is used.
- If the peer is unresolved AND no default is provided:
- For
ref:form, the env var is silently omitted (graceful "ignored" behavior). - For
${...}interpolation form, you must supply:-default; otherwise it is a hard error.
- For
- The supervisor watches each cross-repo peer for changes. When the peer's port or any referenced env var changes (or the peer (de)registers), the dependent service is killed and relaunched with refreshed values.
Notes:
@selfand other reserved names have no special meaning —@<repo>is just whichever string was registered as the peer's repo (typically the basename of the directory containing the peer'smdp.yaml).- Cross-group references require
--link <repo>=<group>. Without it, the peer must be in the same group as the resolver.
Cross-group lookups via --link
Use mdp run --link <repo>=<group> to redirect cross-repo @<repo>.* references to a different group than the caller's. The flag is repeatable and last-wins per repo. Common case: a frontend on a feature branch wiring to a backend that runs on main.
# Frontend on branch derek/foo, backend on main:
mdp run --link api=main
# In the frontend's mdp.yaml, no change needed:
services:
web:
command: npm run dev
env:
VITE_API_URL: "http://localhost:${@api.server.port}"
The override applies to all @<repo>.* references resolved during this mdp run invocation, both at startup and during peer-change watching.
Path resolution
All paths support ~ expansion. Relative paths resolve as follows:
| Field | Base directory |
|---|---|
dir |
mdp.yaml directory |
tls_cert, tls_key |
mdp.yaml directory |
global.env_file |
mdp.yaml directory |
env_file (service-level) |
the service's dir if set, else the mdp.yaml directory |
Absolute paths are used as-is.
# mdp.yaml lives at /repo/mdp.yaml
services:
api:
dir: ./backend # → /repo/backend
tls_cert: ~/certs/dev.pem # → $HOME/certs/dev.pem
env_file: .mdp.env # → /repo/backend/.mdp.env (relative to dir)
web:
# no dir set
env_file: .mdp.web.env # → /repo/.mdp.web.env (falls back to mdp.yaml dir)