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 named svc (single-port services).
  • ${svc.NAME} — a named port from a multi-port service's ports[] entry, where NAME is that entry's env key (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.
  • 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:

  • @self and 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's mdp.yaml).
  • Cross-group references require --link <repo>=<group>. Without it, the peer must be in the same group as the resolver.

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)

← Back to docs index