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.
# Copy it to `devhost.toml` in your project root to use it.
# While a stack runs, saving this file or one of its includes hot-reloads it, and files that
# start or stop matching an include glob are picked up. Invalid edits leave the stack running.
# Every setting reloads, including name, killZombies, devtools, and worktrees.enabled;
# devtools edits restart no service.
# Guides: https://alexgorbatchev.github.io/devhost/stacks/hot-reload/
#
# 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)
# - `[groups]` (optional; named selections of services for `devhost start <name>`)
# - `[worktrees]` (optional; Git checkout selection for services that share a repository, enabled by default)
# - `[devtools]` (optional; idle timeout)
# - `[devtools.configEditor]` (optional; edit the configuration from the browser, off by default)
# - `[devtools.editor]` (optional; Alt + right-click opens component source, enabled by default)
# - `[devtools.externalToolbars]` (optional; third-party devtools launchers, enabled by default)
# - `[devtools.minimap]` (optional; log minimap, enabled by default)
# - `[devtools.resources]` (optional; host CPU, memory, and disk readouts, enabled by default)
# - `[devtools.status]` (optional; service status and toolbar position, enabled by default)
# - `[devtools.shortcuts]` (optional; restart shortcut)
# - `[devtools.chat]` (optional; installed CLI chat, enabled by default)
# - `[devtools.annotations]` (optional; context selector, enabled by default)
# - `[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`
# - `alwaysStart = false` by default; `alwaysStart = true` starts the service on every `devhost start`, including one that names other services
# - `managed = true` by default; `managed = false` means devhost does 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`
# - a health table selects at most one of `tcp`, `http`, or `process`; timing-only tables require a service `port`
# - `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
# - 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
# - text outside placeholders stays literal, as do malformed `{{ ... }}` sequences; an unterminated `{{`
# keeps the rest of that string literal
# - a well-formed placeholder whose variable is unset is a manifest read error
# - a variable set to the empty string expands to the empty string
# `name` labels the stack in logs and devtools. Saving a new name renames the running stack;
# the stack is identified by the path of its root manifest.
# 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 kills processes left by an earlier run of this
# manifest that still hold its hostnames or ports, and reclaims them.
# - `true` (default): stop such a process, log `Stopping zombie PID ...` and `Stopped zombie PID ...`, and continue.
# - `false`: report the claim conflict as an error.
# Running stacks and processes from other manifests are never killed.
# killZombies = true
# `includes` lists file paths or glob patterns (relative to this manifest's directory)
# of other manifests to merge into this stack. Use it in monorepos where each app keeps its own services.
# - Only the services and groups of included manifests are merged, recursively; every other
# setting comes from this root manifest.
# - Service and group names must be unique across all merged manifests.
# - An included service without `cwd` runs in its own manifest's directory, and its relative
# paths resolve from there.
# - Include cycles are ignored.
# Guide: https://alexgorbatchev.github.io/devhost/guides/manifest-includes/
#
# includes = ["packages/*/devhost.toml", "apps/*/devhost.toml"]
# Define groups here or in each app's included devhost.toml.
# `devhost start app` starts web, its dependsOn chain, and alwaysStart services.
# Mix group and service names: `devhost start app worker`. Shared services start once.
# Group names follow service naming rules: lowercase letter, then lowercase letters,
# digits, or hyphens. Names must be unique across includes and distinct from services.
# Each group is a non-empty array of service names from the complete merged manifest;
# nested groups and unknown services are rejected. Groups select services; dependsOn
# defines their dependencies. Plain `devhost start` still starts every service.
# Group edits hot-reload: a group named at startup follows its current members, and deleting
# it drops the services only it selected; alwaysStart and other requested names remain.
[groups]
app = ["web"]
# Managed Caddy is shared by every stack and started by hand for stacks:
# run `devhost caddy start` before starting one or more stacks.
# All stacks share the same managed Caddy instance and the same global 404 hostname list.
# Its admin address, bind host, HTTP enablement, and listener ports are machine settings,
# not manifest settings: set them in the `[caddy]` table of the devhost config file,
# `config.toml` in `$DEVHOST_CONFIG_DIR`, `$XDG_CONFIG_HOME/devhost`, or `~/.config/devhost`.
# A manifest with a `[caddy]` table is rejected.
# The `devtools.*` sections control the browser-side devhost overlay injected into
# routed HTML document navigations. If all devtools features are disabled, apps
# load without the overlay. Startup and recovery pages remain available.
# The repository worktree picker is enabled by default and requires Git on PATH.
# Set enabled = false to disable discovery and saved-selection restore.
# [worktrees]
# enabled = false
# The selected checkout is remembered per root manifest path across restarts.
# Switching loads that checkout's manifest and includes and restarts the whole managed stack;
# the stack then takes that manifest's settings, including its name.
# Repositories containing managed = false services cannot switch.
# Point every cwd of one repository into the same checkout.
# Turning worktrees off while another checkout runs switches back to the root checkout.
# Guide: https://alexgorbatchev.github.io/devhost/stacks/worktrees/
[devtools.configEditor]
# `enabled` controls whether the toolbar shows a Config button for editing this manifest, its
# includes, and the shared config.toml in the browser. Saves pass the checks a reload runs, then
# hot-reload. Only a browser on this machine can save, because the manifest sets the commands devhost runs.
# - `false` (default): no editor, and its control routes answer 404.
# - `true`: show the Config button.
enabled = false
[devtools]
# `idleTimeout` sets the automatic idle shutdown duration (e.g. 30s, 1m, 1h).
# The stack shuts down after this long with no routed Caddy traffic, no open toolbar
# connections, and no chat turn in progress. `--idle-timeout` and `DEVHOST_IDLE_TIMEOUT` override it.
# - string (e.g. "1h"): enable auto-shutdown after the given duration.
# - empty/negative/0 (default): disable auto-shutdown.
# idleTimeout = "1h"
[devtools.chat]
# Native Codex app-server and Claude Code stream-json clients are discovered from PATH.
# Keep each CLI signed in with its normal configuration; no API key is entered into devhost.
# Models come from each CLI's own list; "Configured default" keeps the CLI's default model.
# Conversations run in the selected checkout and survive browser reloads. Approvals always
# need your answer. A message, with its screenshots and pasted PNG/JPEG images, is limited to 8 MiB.
enabled = true
# devhost adds workflow instructions to each message: use a dedicated Git worktree, run checks,
# commit and merge into local main, then remove the worktree and branch.
# `prompt` replaces those instructions; an empty string disables them.
# Changes apply to the next message, including in existing conversations.
# Guide: https://alexgorbatchev.github.io/devhost/guides/annotations/
# prompt = "Implement the requested changes in a worktree and leave the branch for review."
[devtools.annotations]
# Enable element selection and drawing for chat. Sending selected context also requires chat to be enabled.
enabled = true
[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.
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.
# Supported tools:
# - TanStack Query, TanStack Router, and the unified TanStack shell
# - Redux Toolkit and Zustand stores registered through /__devhost__/redux.js
# - Vue DevTools in a Vite DevTools dock
# - React Hook Form DevTool panels and Jotai DevTools panels
# Setup pages: https://alexgorbatchev.github.io/devhost/guides/devtools/#third-party-devtools
# The panels stay the tools' own; devhost only collects their launchers.
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.resources]
# Host CPU, memory, and disk usage in the devtools toolbar, each as percent used. A readout
# turns amber from 70% and red from 90%; hover one for the absolute figures.
# CPU is the share of all logical CPUs that was busy since the previous reading. Disk is the
# space used across fixed local filesystems: on Linux, USB-attached and removable drives are
# left out; on macOS, the startup disk is reported.
# `enabled` controls whether any readout is shown.
# - `true` (default): show the readouts.
# - `false`: hide them; devhost samples nothing.
enabled = true
# `pollInterval` sets how often each readout is sampled, as a duration string of at least "250ms".
# - omitted (default): CPU and memory are sampled every "2s", disk every "1m".
# - string (e.g. "5s"): sample all three at this rate, unless a readout sets its own below.
# pollInterval = "5s"
[devtools.resources.cpu]
# `enabled` shows (`true`, default) or hides (`false`) the CPU readout.
enabled = true
# `pollInterval` overrides the shared rate for the CPU readout.
# pollInterval = "1s"
[devtools.resources.memory]
# `enabled` shows (`true`, default) or hides (`false`) the memory readout.
enabled = true
# `pollInterval` overrides the shared rate for the memory readout.
# pollInterval = "2s"
[devtools.resources.disk]
# `enabled` shows (`true`, default) or hides (`false`) the disk readout.
enabled = true
# `pollInterval` overrides the shared rate for the disk readout.
# pollInterval = "1m"
[devtools.status]
# When a page's own foreground service exits, or one it depends on, the page shows a full-screen
# log view with a restart button. Any other exit only shows in the Services panel.
# devhost and sibling services keep running after a service exits, even with exit code 0.
# `enabled` controls whether service status is shown in the devtools toolbar.
# - `true` (default): show service state and the services panel in the toolbar.
# - `false`: hide service status.
enabled = true
# `position` selects the corner the devtools toolbar is anchored to.
# - `"top-right"`: anchor the toolbar to the top-right corner; its panels open below it.
# - `"bottom-right"` (default): anchor the toolbar to the bottom-right corner; its panels open above it.
position = "bottom-right"
[devtools.shortcuts]
# `restartServices` is the browser shortcut that restarts every service marked `changed`
# (or the primary service when none is), in dependency order.
# - string (e.g. "alt+ctrl+r"): modifiers "ctrl", "alt", "shift", "meta" and one key code, such as
# "r", "enter", or "escape", joined with "+", for example "ctrl+shift+x".
# Omitted or invalid values fall back to "alt+ctrl+r".
restartServices = "alt+ctrl+r"
# -----------------------------------------------------------------------------
# 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"` and `alwaysStart`
# - `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 probe kind => TCP health check on the resolved port, with optional health timing overrides
# - health HTTP templates resolve after every service port is assigned
# - `{{ services.<name>.host }}` names the first public routing host, falling back to bindHost
# - `{{ services.<name>.url }}` builds the direct HTTP base URL, mapping wildcard hosts to loopback and bracketing IPv6
# Static files can run as a native managed service instead of a child command:
# [services.design]
# serve = "packages/design"
# host = "design.localhost"
# alwaysStart = true
# `serve` resolves relative to its declaring manifest, defaults to an automatic port,
# and uses the same confined file browser and Markdown rendering as `devhost serve`.
# Index precedence: index.html, index.md, README.md, readme.md, then a directory listing.
# Configure port/bindHost, HTTP or TCP health, routes, and dependencies as needed.
# `serve` replaces command and rejects cwd, env, injectPort, lifecycle,
# proxyLocalOrigin, watch, managed = false, and health.process.
[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.
# Absolute paths are used directly and may point outside the manifest directory.
# Relative paths resolve against the manifest directory and must stay within it.
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`.
# Startup and configuration reload retry with a new port when another process took the assigned one;
# individual restarts keep the assigned port. "Restart Stack" in the Services panel or on the
# recovery page reassigns automatic ports and rebuilds references across all managed services.
# Allocation is best-effort: nothing coordinates automatic ports across processes.
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 one public routed hostname or a non-empty array of unique hostnames.
# Every hostname routes to the same service process, port, and path.
# The first hostname is used for `DEVHOST_HOST`, `{{ services.<name>.host }}`,
# and the devtools service link. Startup logs list every routed URL.
host = "hello.local.test"
# host = ["hello.local.test", "alias.local.test"]
# `proxyLocalOrigin` rewrites Host, and an Origin that matches the public hostname, to the
# service's local address, for dev servers such as Bun HTML/HMR that reject public hostnames.
# The local address is this service's bindHost (wildcards become loopback) and port, so the
# app must listen exactly there. Defaults to false and requires host. Other origins get 403.
# Guide: https://alexgorbatchev.github.io/devhost/guides/managed-caddy/#development-servers-that-require-a-local-origin
# proxyLocalOrigin = true
# `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.
# `devhost start web` starts this service and everything it reaches through `dependsOn`.
dependsOn = ["api"]
# `watch` lists paths to watch for file changes; relative paths use the service cwd.
# Relative watches follow a selected worktree; absolute watch paths remain unchanged.
# Included services use the same cwd-relative rule. A watch outside the root manifest
# directory prints a warning; it remains active when the path exists.
# - string array (e.g. ["src/"]): when files change, the service is marked `changed` in the devtools toolbar.
# - Folders are watched recursively; node_modules, .git, .workspaces, and .tmp are ignored.
# - Changes are debounced by 200ms.
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"
# The db service binds the IPv6 wildcard address; connect through bracketed IPv6 loopback.
DATABASE_URL = "postgres://postgres:postgres@[::1]:{{ services.db.port }}/mydb"
[services.api.health]
# `http` is a readiness URL, ready on any 2xx response, or a path starting with a single `/`
# on this service. A path is shorthand for `http = "{{ services.api.url }}/healthz"`,
# which uses the assigned port and a connectable bind host, with IPv6 brackets.
# Full URLs must target `127.0.0.1`, `localhost`, or `::1`, for example
# `http = "http://[::1]:{{ services.api.port }}/healthz"`.
# `{{ services.<name>.host }}` is the public hostname; use `url` for direct health checks.
http = "/healthz"
# `interval` sets the delay between HTTP health-check attempts.
# - integer milliseconds `>= 1`; `200` is the default.
interval = 500
# `timeout` sets the maximum total wait time for this health check.
# - integer milliseconds `>= 1`; `30000` is the default.
timeout = 5000
# `retries` limits retries after the first failed attempt.
# - integer `>= 0`; `0` (the default) disables the cap, so only `timeout`
# bounds the total wait. A positive N permits N retries after the first
# failure, giving up after N + 1 consecutive failures or the timeout.
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`, `timeout`, and `retries` work as in `services.api.health`.
interval = 250
timeout = 3000
retries = 10
[services.db]
# `alwaysStart` controls whether the service starts when `devhost start` names other services.
# Naming services starts part of a large stack, such as the monorepo app you are
# working on, without launching the rest. Set `alwaysStart = true` on a service every slice
# needs, such as this database, so it starts whichever services are named.
# - `false` (default): the service starts when it is named, when a started service depends on it,
# or when `devhost start` names no service at all.
# - `true`: the service and everything it reaches through `dependsOn` start on every `devhost start`.
# `devhost start web` starts web, its `dependsOn` chain, and every `alwaysStart` service. The
# services left out hold no process, route, hostname, or port; start one later from
# "Stopped services" in the devtools Services panel, which also starts what it depends on.
# `devhost service list` prints every service name; `--startable` leaves out the `alwaysStart` ones.
alwaysStart = true
# `port = "auto"` allocates a free port; health timing and explicit probes are supported.
command = ["bun", "run", "db:dev"]
cwd = "./db"
port = "auto"
bindHost = "::"
[services.db.health]
# Keep the implicit TCP probe on the assigned port; allow up to 60 seconds for startup.
timeout = 60000
[services.worker]
# A background process with process-based health, which only non-routed services may use.
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`, `timeout`, and `retries` work as in `services.api.health`.
interval = 1000
timeout = 1000
retries = 0