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 herecommand = ["bun", "run", "api:dev"]cwd = "./api"port = 4000bindHost = "::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 = 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`, `timeout`, and `retries` work as in `services.api.health`.interval = 250timeout = 3000retries = 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 = 1000timeout = 1000retries = 0