Manifest reference
The canonical manifest reference is generated from apps/devhost/devhost.example.toml.
# devhost example manifest## This file is meant to be read as documentation first and copied second.# Rename it to `devhost.toml` in your project root when you actually want to use it.## Top-level sections:# - `name` (required)# - `killZombies` (optional; boolean to automatically kill zombie processes from the same manifest, default `true`)# - `includes` (optional; string or string array of file glob patterns to merge multiple sub-manifests)# - `[caddy.global]` (optional)# - `[devtools]` (optional)# - `[devtools.editor]` (optional)# - `[devtools.externalToolbars]` (optional)# - `[devtools.minimap]` (optional)# - `[devtools.status]` (optional)# - `[devtools.shortcuts]` (optional)# - `[annotation]` and `[[annotation.actions]]` (optional; annotation actions are unavailable when omitted)# - `[services.<name>]` (required; at least one service)## Hard validation rules:# - service names must match `^[a-z][a-z0-9-]*$`# - every service must define either `port` or `health`# - `managed = true` by default; `managed = false` means devhost must not start or restart that service# - daemon lifecycle services use `[services.<name>.lifecycle]` with `mode = "daemon"`# - daemon lifecycle services must keep `managed = true`, must omit top-level `command`, and must define `lifecycle.start` and `lifecycle.stop`# - daemon lifecycle services must not use `port = "auto"` or `health.process`# - a routed service (`host = ...`) must also define `port`# - a routed service must not use `health.process`# - `port = "auto"` must omit `health` in v1# - `managed = false` must omit `command`, `injectPort`, and `port = "auto"`# - `managed = false` may use explicit TCP or HTTP health, but must not use `health.process`# - `bindHost` must be one of: `127.0.0.1`, `0.0.0.0`, `::1`, `::`# - `command` is best written as a string array; string form is supported but split on whitespace# - fixed numeric bind ports are reserved across live devhost processes before spawn# - hostname ownership is exclusive across projects, but one manifest may mount multiple services under the same host on distinct paths# - annotation action ids must match `^[a-z][a-z0-9-]*$` and be unique# - string values support environment-variable interpolation with `{{ env.NAME }}` placeholders# - placeholder names must start with a letter or underscore and then use letters, digits, or underscores# - other text stays literal# - malformed `{{ ... }}` text stays literal, and an unterminated `{{` keeps the rest of that string literal# - referencing an undefined valid placeholder is a manifest read error# - defined placeholders may expand to the empty string
# `name` identifies the stack in logs, state, and injected metadata.# You can interpolate environment variables in string values, for example:# `name = "{{ env.USER }}-stack"` or `host = "devhost-{{ env.USER }}.localhost"`.name = "hello-stack"
# `killZombies` controls whether devhost automatically kills and reclaims zombie# processes claiming the same hosts or ports from the same manifest path.# - `true` (default): find and kill any active devhost child or main process from the same manifest, log a message, and succeed.# - `false`: abort and report the claim conflict as an error.# killZombies = true
# `includes` lists file paths or glob patterns (relative to this manifest's directory)# of sub-manifests to load and merge. This is perfect for monorepos where each sub-application# maintains its own services.# - Included manifests' services and annotation actions are recursively merged.# - Service names must be unique across all merged manifests.# - Relative paths (like `cwd` and `watch` folders) in included manifests are automatically# rewritten to be relative to the root manifest's directory.# - If `cwd` is missing in an included service, it defaults to the directory of the manifest# it was defined in.# - Cyclic references are safely and gracefully ignored.## includes = ["packages/*/devhost.toml", "apps/*/devhost.toml"]
# Managed Caddy lifecycle is shared and manual.# Start it explicitly with `devhost caddy start` before running one or more stacks.# All stacks share the same managed Caddy instance and the same global 404 hostname list.
# The `devtools.*` sections control the browser-side devhost overlay injected into# routed HTML document navigations. If all devtools features are disabled, routed# services proxy directly to the app without the overlay.
[caddy.global]# `http` also serves the same routed hosts and the shared fallback page on plain HTTP.# - `false` (default): serve through HTTPS only.# - `true`: also serve the same routes through HTTP on `httpPort`.# `httpPort` selects the managed Caddy HTTP listener port.# - `80` (default): standard privileged HTTP.# - any other integer from `1` to `65535`: alternate HTTP listener port.# `httpsPort` selects the managed Caddy HTTPS listener port.# - `443` (default): standard privileged HTTPS.# - any other integer from `1` to `65535`: alternate HTTPS listener port.# `bindHost` controls which interfaces the managed Caddy HTTP/HTTPS listeners bind.# - `"127.0.0.1"` (default): loopback only.# - `"0.0.0.0"`: expose the managed Caddy front door on all IPv4/IPv6 interfaces.# - `"::1"`: IPv6 loopback only.# - `"::"`: expose the managed Caddy front door on all IPv6 interfaces.# `adminAddress` controls which loopback address/port the managed Caddy admin API listens on.# - `"127.0.0.1:20197"` (default): the default devhost-managed admin endpoint.# - any other host:port string Caddy accepts: alternate admin endpoint.## Important: this is a shared managed-Caddy setting, not an isolated per-stack listener.# If any active stack enables `caddy.global.http = true`, the shared Caddy instance serves# HTTP for all active stacks until the last opting-in stack stops.# Active stacks must also agree on any non-default `adminAddress`, `bindHost`, `httpPort`, and `httpsPort` values.adminAddress = "127.0.0.1:20197"bindHost = "127.0.0.1"httpPort = 80http = falsehttpsPort = 443
[devtools.editor]# `enabled` controls whether editor integration is available in the injected overlay.# - `true` (default): enable Alt + right-click component-source navigation when source metadata is available.# - `false`: disable editor integration.enabled = true# `ide` selects which editor target devhost opens for component-source navigation.# - `"vscode"` (default): open Visual Studio Code via its browser URL handler.# - `"vscode-insiders"`: open VS Code Insiders via its browser URL handler.# - `"cursor"`: open Cursor via its browser URL handler.# - `"webstorm"`: open WebStorm via its browser URL handler.# - `"neovim"`: open Neovim inside devhost's embedded terminal UI; requires `nvim` and `curl` on PATH.# Devhost loads its bundled Neovim plugin for that terminal session so cursor movement in TSX/JSX buffers can# highlight the matching React component in the routed browser page for the same devhost instance.# While the stack runs, `.tmp/devhost/<stack-name>/nvim-shell/bin/devhost-nvim` launches Neovim from a shell with# the same instance token and plugin path.ide = "vscode"
[devtools.externalToolbars]# `enabled` controls whether devhost aggregates detected third-party devtools launcher buttons.# - `true` (default): hide supported native launcher buttons and re-render them inside the devhost overlay.# - `false`: leave third-party launcher buttons untouched.enabled = true
[devtools.minimap]# `enabled` controls whether the injected log minimap UI is shown.# - `true` (default): show the log minimap in the overlay.# - `false`: hide the log minimap.enabled = true
[devtools.status]# `enabled` controls whether the injected service-status panel is shown.# - `true` (default): show stack and service state in the overlay.# - `false`: hide the status panel.enabled = true# `position` selects where the status panel is anchored.# - `"top-right"`: anchor the panel to the top-right corner.# - `"bottom-right"` (default): anchor the panel to the bottom-right corner.position = "bottom-right"
[devtools.shortcuts]# `restartServices` defines a global keyboard shortcut to trigger a parallel, topological restart# of all dirty (modified) services in the browser.# - string (e.g. "alt+ctrl+r"): the keyboard shortcut.# Supported modifiers: "ctrl", "alt", "shift", "meta", combined with "+" and any physical layout-independent key code (like "r", "enter", "escape").# Syntaxes must be separated by "+", for example: "ctrl+shift+x".# If invalid syntax is provided or if omitted, it defaults back to "alt+ctrl+r".restartServices = "alt+ctrl+r"
# `idleTimeout` sets the automatic idle shutdown duration (e.g. 30s, 1m, 1h).# If the daemon receives no routed Caddy traffic and has no persistent browser WebSocket# connections or active interactive terminal sessions for this duration, it automatically# stops all started services and cleanly shuts down the stack.# - string (e.g. "1h"): enable auto-shutdown after the given duration.# - empty/negative/0 (default): disable auto-shutdown.# idleTimeout = "1h"
# Annotation actions are configured only through `[annotation]`.# If `[annotation]` is present, `[[annotation.actions]]` must contain at least one action.# If `[annotation]` is omitted, annotation submission is unavailable in the injected UI.## [annotation]# defaultAction = "fix"## [[annotation.actions]]# id = "fix"# label = "Ask Claude"# kind = "agent"## [annotation.actions.agent]# adapter = "claude-code"## Agent actions support the built-in adapters `"pi"`, `"claude-code"`, and `"opencode"`.# Custom agent actions omit `adapter` and use `displayName`, `command`, optional `cwd`, and optional `env`# inside `[annotation.actions.agent]`.## [[annotation.actions]]# id = "jira"# label = "Create Jira Ticket"# kind = "command"## [annotation.actions.command]# command = ["bun", "./scripts/mock-jira-ticket.ts"]# cwd = "."## [annotation.actions.command.env]# JIRA_BASE_URL = "https://example.atlassian.net"# JIRA_PROJECT_KEY = "WEB"## Command actions run directly in a devhost terminal with annotation context files exposed via:# - `DEVHOST_ANNOTATION_ACTION_ID`# - `DEVHOST_ANNOTATION_ACTION_KIND`# - `DEVHOST_ANNOTATION_ACTION_LABEL`# - `DEVHOST_ANNOTATION_DISPLAY_NAME`# - `DEVHOST_ANNOTATION_FILE`# - `DEVHOST_ANNOTATION_PROMPT_FILE`# - `DEVHOST_ANNOTATION_TRANSPORT=files`# - `DEVHOST_PROJECT_ROOT`# - `DEVHOST_STACK_NAME`## A Jira ticket integration should be modeled as a `kind = "command"` action like the# example above. `devhost` does not provide a built-in Jira adapter; your script or CLI# should read `DEVHOST_ANNOTATION_FILE` or `DEVHOST_ANNOTATION_PROMPT_FILE` and create# the ticket itself.## Durable annotation queues are supported for agent actions only.
# -----------------------------------------------------------------------------# services# -----------------------------------------------------------------------------# The examples below document service fields in place instead of listing detached# option tables up here. Read them top to bottom:# - `services.web` shows a primary routed service with a fixed port# - `services.api.health` shows explicit HTTP health# - `services.preview` shows an externally managed routed service# - `services.mailpit` shows a managed daemon lifecycle service# - `services.cache.health` shows explicit TCP health# - `services.db` shows `port = "auto"`# - `services.worker.health` shows process health## Health defaults still matter even when no explicit `[services.<name>.health]`# table is present:# - fixed numeric `port` + no `health` => implicit TCP health check on `bindHost:port`# - `port = "auto"` + no `health` => implicit TCP health check on the resolved port
[services.web]# `primary` marks this as the default service for stack-level behavior.# - `true`: make this the primary service.# - `false` (default): do not mark this service as primary.primary = true# `managed` controls whether devhost owns the service process.# - `true` (default): devhost starts, stops, and can restart the child process.# - `false`: devhost only claims the route/port and health-checks the already-running service.# Daemon lifecycle services still use `managed = true`; they switch ownership mode with `[services.<name>.lifecycle]`.# managed = false# `command` defines the child process command line.# - string array (recommended): preserves argument boundaries exactly.# - string: supported, but devhost splits it on whitespace and does not preserve shell quoting.# - commands run directly, not through an implicit shell.# - `command = ["storybook", "dev", "--port", "$PORT"]` passes literal `$PORT`.# - use an explicit shell such as `command = ["sh", "-c", "exec storybook dev --port \"$PORT\""]`# only when you intentionally want shell expansion or shell features.# Omit `command` when `[services.<name>.lifecycle].mode = "daemon"`.command = ["bun", "run", "web:dev"]# command = "bun run web:dev"# `cwd` sets the working directory for the child process.cwd = "./app"# `port` sets the runtime listening port or requests automatic allocation.# - integer `1..65535`: use the exact port you provide.# - `"auto"`: allocate a best-effort free port before spawn and inject it as `PORT`.# devhost retries on clear bind collisions, but v1 does not provide a cross-process global auto-port allocator.port = 3000# `injectPort` controls whether devhost exports `PORT` to the child process.# - `true` (default): inject `PORT` when `port` is set.# - `false`: keep routing and health on the configured port, but do not export `PORT`.# Use this for composite commands that launch multiple child processes and should not all inherit the same port.# - injecting `PORT` does not expand `$PORT` inside `command`; that still requires an explicit shell.# injectPort = false# `bindHost` sets the socket interface the child process should bind to.# - `"127.0.0.1"` (default): IPv4 loopback only.# - `"0.0.0.0"`: all IPv4 interfaces.# - `"::1"`: IPv6 loopback only.# - `"::"`: all IPv6 interfaces.bindHost = "127.0.0.1"# `host` sets the public routed hostname.host = "hello.local.test"# `path` sets a subpath for mounting (e.g. "/api/*"). Defaults to `"/"`.# Use same-host composition only within one manifest, for example:# - `services.web.host = "hello.local.test"`, `services.web.path = "/"`# - `services.api.host = "hello.local.test"`, `services.api.path = "/api/*"`# path = "/api/*"# `dependsOn` declares services that must start before this service.dependsOn = ["api"]# `watch` lists relative paths within the manifest directory to watch for file changes.# - string array (e.g. ["src/"]): when files change, the service turns red (dirty) in the Status Panel.# - All folders are watched recursively. Heavy paths (e.g., node_modules, .git, .workspaces, .tmp) are automatically ignored.# - Rapid concurrent modifications are debounced by 200ms automatically.watch = ["src/"]# No explicit `[services.web.health]` block is used here.# Because `port = 3000`, devhost will apply the default TCP health check for `127.0.0.1:3000`.
[services.web.env]# `env` adds app-specific environment variables for this managed service.NODE_ENV = "development"PUBLIC_API_ORIGIN = "https://api.hello.local.test"
[services.api]# A routed backend with explicit HTTP health and IPv6 loopback binding.# This example uses its own hostname, but it could also share `hello.local.test`# under a distinct path such as `path = "/api/*"`.## Docker-backed variant:# - publish the container port onto the host, for example Compose `ports: ["4000:4000"]`# - keep `port = 4000` here because devhost routes to the host-visible port# - replace the command with `command = ["docker", "compose", "up", "--build", "api"]`# - keep HTTP health checks on `127.0.0.1`, `localhost`, or `::1`; Docker-internal names such as `api` are not valid herecommand = ["bun", "run", "api:dev"]cwd = "./api"port = 4000bindHost = "::1"host = "api.hello.local.test"dependsOn = ["db"]
[services.api.env]LOG_LEVEL = "debug"# Use late-binding template references to get the dynamically allocated port and bind host of the `db` service:DATABASE_URL = "postgres://postgres:postgres@{{ services.db.bindHost }}:{{ services.db.port }}/mydb"
[services.api.health]# `http` defines an absolute HTTP health-check URL.# - absolute URL using host `127.0.0.1`.# - absolute URL using host `localhost`.# - absolute URL using host `::1`.http = "http://127.0.0.1:4000/healthz"# `interval` sets the delay between HTTP health-check attempts.# - integer `>= 1`; `200` is the default.interval = 500# `timeout` sets the maximum total wait time for this health check.# - integer `>= 1`; `30000` is the default.timeout = 5000# `retries` sets how many extra failed attempts are allowed.# - integer `>= 0`; `0` is the default.retries = 20
[services.preview]# An externally managed routed service.# Use this when another tool or wrapper command owns the process lifecycle and later opens the port.managed = falsedependsOn = ["api"]port = 4100host = "preview.hello.local.test"# Fixed-port unmanaged services still get the default TCP health check on `127.0.0.1:4100`.# Add an explicit `[services.preview.health]` block if you want custom TCP or HTTP health instead.
[services.mailpit]# A managed daemon lifecycle service.# Use this when devhost should own start/stop, but the service backgrounds itself instead of staying as one foreground child.port = 8025host = "mail.hello.local.test"
[services.mailpit.lifecycle]# `mode = "daemon"` switches from the default foreground `command` model to explicit lifecycle commands.mode = "daemon"# `start` launches or enables the daemonized service.start = ["./scripts/mailpit-devctl", "start"]# `status` is optional. When it exits `0`, devhost treats the daemon as already running and skips `start`.status = ["./scripts/mailpit-devctl", "status"]# `stop` shuts the daemon down during stack shutdown, cleanup, and service restart.stop = ["./scripts/mailpit-devctl", "stop"]
[services.mailpit.health]# Daemon lifecycle services still need normal TCP or HTTP health checks.http = "http://127.0.0.1:8025/api/v1/info"
[services.cache]# A non-routed service using explicit TCP health instead of `port`.command = ["bun", "run", "cache:dev"]cwd = "./cache"bindHost = "0.0.0.0"dependsOn = ["db"]
[services.cache.health]# `tcp` defines the port used by the TCP health check.# - integer `1..65535`.tcp = 6379# `interval` sets the delay between TCP health-check attempts.# - integer `>= 1`; `200` is the default.interval = 250# `timeout` sets the maximum total wait time for this health check.# - integer `>= 1`; `30000` is the default.timeout = 3000# `retries` sets how many extra failed attempts are allowed.# - integer `>= 0`; `0` is the default.retries = 10
[services.db]# `port = "auto"` is valid, but `health` must be omitted in v1.command = ["bun", "run", "db:dev"]cwd = "./db"port = "auto"bindHost = "::"
[services.worker]# A background process with process-based health.# This is valid only for non-routed services.command = ["bun", "run", "worker:dev"]cwd = "./worker"dependsOn = ["api"]
[services.worker.health]# `process` treats the service as healthy while the child process remains alive.# - `true`: enable process-based health checks.process = true# `interval` sets the delay between process-health checks.# - integer `>= 1`; `200` is the default.interval = 1000# `timeout` sets the maximum total wait time for this health check.# - integer `>= 1`; `30000` is the default.timeout = 1000# `retries` sets how many extra failed attempts are allowed.# - integer `>= 0`; `0` is the default.retries = 0