-
Notifications
You must be signed in to change notification settings - Fork 0
services
Services are configuration objects. devctl never hard-codes names, ports, or commands.
services:
api:
description: Invoices API
command: [python3, main.py] # argv preferred
shell: false # set true only when a shell is required
working_dir: invoices-api # relative to repository root
dependencies: [identity]
ports:
http: 8000 # or auto
environment:
AUTH_URL: http://127.0.0.1:${services.identity.ports.http}
required: [AUTH_URL]
defaults:
LOG_LEVEL: INFO
health:
type: http # http | tcp | process | command | grpc
url: http://127.0.0.1:8000/health
interval_seconds: 2
timeout_seconds: 1
start_period_seconds: 10 # early failures remain STARTING
unhealthy_threshold: 3 # consecutive failures before restart
healthy_reset_threshold: 10 # healthy checks before retry budget resets
identity:
type: user # or service_account
proxy: # optional; merged into the global proxy at load
match: { path: /api }
upstream: { url: http://127.0.0.1:8000 }
expose: true # optional; reach it through the proxy at api.local
restart:
policy: on_failure # never | on_failure | always
max_retries: 3
backoff_seconds: 2
startup:
wait_for_healthy: true
timeout_seconds: 20
capabilities: [local_http]
logs:
stdout: true
stderr: true
hooks:
pre_start: [python3, migrate.py]
post_start: [python3, warm_cache.py]Working directories resolve from the repository root (the directory that contains .devctl), not the process cwd.
The TUI services screen renders each service's live inspector — status, facts, and the fully resolved environment (dotenv, profile, secrets, plugins, runtime ports), with secret-like values redacted:

${services.<name>.ports.<port>} interpolates another service's port; ${services.<name>.url} and ${services.<name>.host} give a stable base address that routes through the proxy when the target is exposed (see Proxy → Expose). ${identity.user} in service env resolves at process start to the running developer's detected Google email — handy in shared config as LOCAL_USER_EMAIL: ${identity.user} (each developer gets their own, nothing hardcoded); it is also injected automatically as DEVCTL_USER_EMAIL. The same placeholder in proxy route auth.headers (including a service proxy: fragment) is not resolved — it stays literal. Put the identity on the process via service env, not on the hop. expose: true publishes the service through the proxy at <service>.local when proxy.enabled is true; proxy.gateway: true does the same for every HTTP service at once. Neither flag creates a route if the proxy is off.
Without shell: true, a command runs as argv and never goes through a shell, so shell syntax in it fails validation:
-
String command (
command: "npm run dev"): split on whitespace. Fails if any word is|,||,&&,;,>,>>,<, or&, or contains|,;, or&&(echo hi;rm x). -
Array command (
command: [node, -e, "a(); b()"]): each item is passed to the process unchanged. Fails only if an item is exactly one of those operators.;,|, and&&inside an item are ordinary characters, so inline scripts (python -c "a; b"), SQL (psql -c "select 1; select 2"), URLs, and regexes need no shell.
pre_start runs to completion before an explicitly started service is spawned. A non-zero exit fails the service and prevents launch. post_start runs after a successful spawn; failure stops and marks the service failed. Hooks use the service's resolved environment, working directory, and shell setting. They do not run during automatic crash or health restarts.
Top-level tasks are transient commands: they are not retained as services and do not restart. Declared service dependencies are started first, and the task uses the same layered environment resolution as services.
tasks:
migrate:
command: [python3, migrate.py]
working_dir: invoices-api
dependencies: [postgres]
environment:
MODE: developmentRun one with devctl run migrate. Its exit code determines command success, and its stdout/stderr are also captured in supervisor logs under task:migrate.
Use devctl exec api -- python3 check.py to run an ad-hoc command in a service's same resolved context without starting it. devctl exec api --print-env inspects that context with secrets redacted by default.
Define more than one env map on the same service — local vs deployed-dev, for example — and switch them independently of other services and of start profiles:
services:
api:
environment:
AUTH_URL: http://127.0.0.1:${services.identity.ports.http}
environments:
local:
AUTH_URL: http://127.0.0.1:${services.identity.ports.http}
deployed:
AUTH_URL: https://identity.dev.example.com
default_environment: locale / /env in the TUI, devctl env api deployed, the web console Env column, or MCP set_service_environment selects the overlay for that service only. See Environment.
Experimental.
environment.terraformmay change without a deprecation period. See Experimental features.
environment.terraform reads literal env values from a service's .tf file or directory (env blocks and environment_variables maps) so those values live in Terraform instead of a second copy in YAML. An explicit YAML key still overrides. See Environment.
Set container.image to let devctl own a Docker or Podman container with the
same dependency, health, logging, restart, and shutdown lifecycle as a host
service. runtime defaults to Docker. A service command, when present,
overrides the image command.
services:
postgres:
ports:
db: 15432
container:
image: postgres:16
runtime: docker
ports:
db: 5432 # service port name → port inside the container
env:
POSTGRES_PASSWORD: local
volumes:
- pgdata:/var/lib/postgresql/data
health:
type: tcp
address: 127.0.0.1:15432Container names are deterministic and scoped to the stack (the checkout, or a
named --instance of it), allowing a new devctl daemon to adopt containers left
running by its predecessor. Named volumes are scoped the same way:
pgdata above is devctl-<id>-pgdata to the runtime, filled on first use from
seed_from or the unprefixed pgdata. shared_volumes lists named volumes
every stack mounts as written. See parallel stacks. Secret
environment values are supplied through the runtime process environment and
are not placed in command-line arguments. Published ports bind to
127.0.0.1 by default rather than every network interface. Every run also
applies --memory 1g, --cpus 1, and --pids-limit 256 unless you set
container.memory, container.cpus, or container.pids_limit. Optional
container.user, container.read_only, and container.cap_drop harden
further; Doctor warns when the image USER is root. Containers do not
inherit the caller's entire shell environment; profile, dotenv, secrets.env,
keychain, secret-manager, defaults, explicit service/container variables, plugin
sources, and non-secret runtime metadata still apply. devctl down stops and removes
managed containers; container exit codes feed the normal restart policy.
stateDiagram-v2
[*] --> STOPPED
STOPPED --> STARTING
STARTING --> RUNNING
STARTING --> FAILED
RUNNING --> HEALTHY: probe ok
RUNNING --> UNHEALTHY: probe fail
HEALTHY --> UNHEALTHY: probe fail
UNHEALTHY --> HEALTHY: probe ok
HEALTHY --> STOPPING
UNHEALTHY --> STOPPING
RUNNING --> STOPPING
STOPPING --> STOPPED
FAILED --> RESTARTING: on_failure / always
STOPPED --> RESTARTING: always
RESTARTING --> STARTING
The TUI and CLI display HEALTHY / UNHEALTHY when the process is running and the health probe has an answer.
Independent services in the same wave start and stop in parallel. After a start wave, members that a later wave depends on with condition: service_healthy must become healthy before that later wave launches (the same timeout as startup.timeout_seconds, default 30s). Other members only need to have spawned. The last wave returns once processes are up unless a service sets startup.wait_for_healthy. Cycles are configuration errors.
Dependencies accept either the original string form or a condition:
dependencies:
- identity
- service: postgres
condition: service_healthyservice_started is the default and allows the dependent to launch once its dependency process has spawned. service_healthy waits for the dependency's health check. A dependency using service_healthy must define a health check; startup fails on its normal startup timeout if it never becomes healthy.
Named start expands up the dependency graph (startupPlan): starting invoices-worker also starts identity and invoices-api, in that order. Starting a profile does not: omitted members stay remote even if YAML names them as dependencies. See Profiles.
Breaking change: stop no longer mirrors start.
devctl stop xstopsxand everything that (transitively) depends onx— neverx's own dependencies, which other running services may still need (shutdownPlan). Previously, stopping a leaf also stopped the dependencies it had pulled in; that direction was backwards and is not preserved.
flowchart LR
identity --> api["invoices-api"] --> worker["invoices-worker"]
Stopping identity also stops invoices-api and invoices-worker (both depend on it, transitively). Stopping invoices-api also stops invoices-worker, but leaves identity running — it's invoices-api's dependency, not its dependent. Empty stop stops every started service but leaves the daemon itself running.
devctl restart x restarts only x — never its dependents. Pass --cascade to also restart everything that depends on x (the same set a stop x would affect). Either way, a restarted service's own dependencies are started first if they aren't already running, exactly like a plain start would. Waves run left to right on start; stop and cascading restart run them right to left, restricted to whichever services are actually in scope.
A service's restart count (against max_retries) resets to zero on a manual stop or start — including the stop/start half of a restart a client asks for — and also forgives itself once the service has run healthily for long enough. Only an automatic, health-triggered restart preserves the count across its own stop/start cycle; that's what makes max_retries actually a limit instead of resetting itself every cycle.
devctl start with no profile and no service names starts the active session profile, or the first configured profile — the same contract as MCP start_services. Use --profile or explicit names. With no profiles, start fails closed.
Default TUI profile (empty dashboard enter) is the first profile name alphabetically.
type |
Probe |
|---|---|
http |
GET url; 2xx is healthy (default interval 2s, timeout 2s) |
tcp |
Connect to address or a named port — “is this port accepting connections” |
grpc |
grpc.health.v1.Health/Check on address (host:port) over h2c, with TLS/h2 if cleartext is refused. Optional grpc_service is the Health protocol service name (empty = overall). SERVING is healthy; NOT_SERVING, SERVICE_UNKNOWN, and RPC failure are not |
command |
Run health.command; exit 0 is healthy |
process or empty |
PID still alive |
type: grpc only proves that some process answered Health/Check (or a Temporal frontend if address points at the proxy). A Temporal worker that does not expose Health is still process-healthy while disconnected — use type: command or a plugin healthChecks for that case. Do not invent Temporal-specific poll-success health in core.
health:
type: grpc
address: "127.0.0.1:9090"
grpc_service: ""
interval_seconds: 30health.url and health.address accept ${services.<name>.…} references (.port, .ports.<name> or a fixed port's index, .host, .url), expanded before every probe from the ports currently assigned. This lets an http or grpc check follow ports: auto, including another service that restarts on a new port:
ports:
http: auto
health:
type: http
url: http://127.0.0.1:${services.api.ports.http}/healthOther health fields, and other reference kinds (${env.NAME}, ${identity.user}, ${http.…}), are not expanded there. devctl config validate rejects them, and any reference to an unknown service or port, naming the field. type: tcp with no address already checks the assigned http port, or the first port.
During health.start_period_seconds, failing probes leave the service in its startup state and do not contribute to restart streaks. Status snapshots expose start_period_remaining_ms and start_period_total_ms on a service while that window is still open. Afterward, health.unhealthy_threshold consecutive failures trigger the configured restart policy (default 3) only if the process has not yet had a successful probe. Once a probe has succeeded, later failures mark the service UNHEALTHY but leave the process running so an in-process reloader (Vite HMR, bun --watch, a compile error the next save will fix) can recover. A process that actually exits still follows restart.policy. startup.wait_for_healthy uses the same grace window as start-period, so a slow first bind cannot race the startup wait and kill the process. health.healthy_reset_threshold consecutive successes forgive prior restart attempts (default 10).
devctl watches .devctl/ and offers reload. Source-file restart is opt-in per service — off by default so a noisy tree cannot bounce the fleet:
services:
api:
watch:
enabled: true
paths: ["invoices-api"] # relative to the repo root; never the whole checkout
debounce_ms: 300
ignore: ["**/node_modules/**", "**/.git/**"]A matching change restarts only that service. Leave watch off and put air, bun --watch, or your language’s reloader in command when you want in-process reload instead.
A service a reload adds shows up immediately, stopped — not just once it's first started. One a reload removes is forgotten immediately if it was already stopped; if it's still running, it becomes orphaned: still visible and stoppable by name (devctl stop <name>), but no longer restartable or reachable by a cascade, since there's no configuration left to describe how. Stopping it drops it from status entirely. A reload that references a health or identity type nothing provides — a plugin type from a service just added or edited — is rejected outright, the same as an unparseable config file, and the daemon keeps running on its last-known-good configuration.
Plugins can register extra health types. capabilities document intent (local_http, google, iap, …) for doctor; they do not start processes.
Start
- How it fits together
- Installation
- Quick start
- Onboard your repository
- Examples & recipes
- Developer setup
- Agent skills
Use
Configure
Identity
Reference