Skip to content

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 = 80
http = false
httpsPort = 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 here
command = ["bun", "run", "api:dev"]
cwd = "./api"
port = 4000
bindHost = "::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 = false
dependsOn = ["api"]
port = 4100
host = "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 = 8025
host = "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