Skip to content

services

github-actions[bot] edited this page Sep 26, 2026 · 23 revisions

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:

The services inspector showing billing-console — health, command, workdir, ports, and its resolved environment with DEVCTL_INTERNAL_TOKEN and DEVCTL_TOKEN_URL 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.

Hooks and one-off tasks

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

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

Named environment overlays

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

e / /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.

Terraform environment

Experimental. environment.terraform may 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.

Container services

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

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

Lifecycle

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
Loading

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_healthy

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

Start, stop, restart

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 x stops x and everything that (transitively) depends on x — never x'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"]
Loading

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.

Health

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

health.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}/health

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

Related

Clone this wiki locally