devsandboxes.toml is devsandbox’s single source of truth: one file, outside any project, describing every sandbox of a workspace (separate config dirs split workspaces, e.g. work vs personal, not projects). It replaces per-repo devcontainer.json (same property names, TOML syntax, plus devsandbox extras).
File layout
devsandboxes.toml lives in the config dir (-C <dir>, default .). devsandboxes.yaml / devsandboxes.yml are read instead when present: same schema, merge rules and validation, only the syntax differs (sandbox: {web: {image: …}} for [sandbox.web]), and a YAML config hashes the same as its TOML equivalent, so switching formats doesn’t flag drift. YAML null is not a valid value. More than one of the three in a config dir is an error. A legacy config.toml (pre-0.5.1 name) is still read when none exist, and is renamed to devsandboxes.toml the next time devsandbox runs there; one without a sandbox, template or services table is not a devsandbox config and is left alone.
Three top-level tables, all optional:
[template.<name>] # reusable, never run directly
[sandbox.<name>] # runnable; `devsandbox run <name>`
[services.<name>] # sidecar containers a sandbox lists in `services`Next to it devsandbox uses: shared-volumes/ (${sharedVolumes}, persistent host state), .worktrees/<instance-id>/ (repeat-instance git worktrees), .worktrees/<instance-id>.folders/<basename>-<hash>/ (folders worktrees), shared-files/<folder-basename>-<hash>/ (worktree-link store, one per base folder). Relative paths (folder, folders values, shell-rc, build.dockerfile, build.context) resolve against the config dir.
Templates and extends
extends = "name"orextends = ["a", "b"], on sandboxes and templates (recursive). Only templates can be extended. Cycles and unknown names error.- Merge order: listed templates left-to-right, then the table’s own body.
- Merge rules:
- tables merge recursively (
containerEnv,build.args,customizations…) - arrays concatenate, base first (
mounts,caches,services,shell-rc,customizations.vscode.extensions…). There is no way to remove an inherited entry. Concatenation is what the two main use cases need:- VS Code extensions are inherited: a template’s
customizations.vscode.extensionsstay installed when a sandbox adds its own. - Mounts are inherited: a template’s
mounts(agent binary, credentials, shared auth) reach every sandbox without restating them. A downstream table overrides an inherited mount by mounting the same target again: the last entry for a target wins, the earlier ones are dropped. A more specific target inside an inherited mount is not an override; both apply, in any order in the config: devsandbox mounts parents before the mounts nested inside them, on every runtime.
- VS Code extensions are inherited: a template’s
- scalars: the later value wins.
- tables merge recursively (
- Diamond inheritance duplicates concatenated array entries (not deduped).
- After merging, the result must be a valid sandbox (below). Templates are only validated through the sandboxes that extend them.
Sandbox properties
Keys are devcontainer camelCase, except devsandbox extras noted in kebab-case. Unknown keys are a hard error, so typos surface immediately.
devsandbox extras
| key | type | meaning |
|---|---|---|
folder |
string | Host project dir (relative to config dir, must exist). Required by run. A second instance of the same folder gets a git worktree, as does an instance whose folder another instance already mounts as a direct folders entry. |
services |
[string] | Names from [services.*] to start and attach. |
caches |
[string] | Shared package caches: pnpm, cargo, npm, yarn, go, pip. Each = bind mount under shared-volumes/<sub> + env vars pointing the tool at it (targets are /root/...). Unknown name errors. |
persist-shell-history |
bool | Per-instance shared-volumes/history/<id>/.zsh_history, dir mounted at /commandhistory, HISTFILE set (env + rc line). Kept on rm. |
worktree-branch |
string | Branch pattern for worktree instances, ${…} vars allowed. Default sandbox/${instance}. run --branch overrides. An existing local branch is checked out as is (worktree-base ignored); one only on origin becomes a local branch tracking it; else a new branch is created. A branch checked out in another worktree is an error. |
worktree-base |
string | Commit-ish start point (e.g. origin/develop). Default: remote’s default branch. run --base overrides. |
worktree-include |
[string] | Gitignore-syntax patterns: gitignored, untracked files copied from the base repo into each worktree on run and again on every rebuild (never overwriting, so a copy deleted on purpose comes back on the next rebuild; start doesn’t copy). Added after the repo’s own .worktreeinclude (always honored), so !pat can negate it. See docs/architecture/instances/worktrees.md. |
worktree-link |
[string] | Gitignored paths from the repo root (.env = root only; leading ./ ok) shared live by all instances. */? glob within one segment (packages/*/.env), matched against existing paths in the base repo and store; no **/[...]. For each: the base repo’s file is moved to shared-files/<folder-basename>-<hash>/<path> (one store per base folder, shared by every sandbox on it; links into old shared-files/<sandbox>/ stores are skipped with a warning, migrate them by hand) and each tree gets an absolute symlink; that dir is mounted at its host path (works on Apple container). Tracked/non-ignored paths and existing conflicting files are skipped with a warning; ../absolute/.git entries error. Store kept on rm. |
shell-rc |
[string] | Host shell snippets sourced by ~/.zshrc/~/.bashrc. Must exist and be files (no auto-create). Parent dir mounted read-only at /devsandbox/rc/<i>. |
folders |
{container path = host dir | { path, worktree = "auto"|"always"|"never" }} |
Extra VS Code workspace roots. Key must be absolute and differ from workspaceFolder; path (or the plain string) must exist, ${…} vars allowed. worktree (default "auto"; unknown keys/values error): "auto" gives the instance its own git worktree when the dir is a git repo root and it’s owned (some sandbox’s folder in this config, or the base folder of any existing instance, from any config root), already directly mounted by another instance, or the instance is a dispatcher child; else a direct bind. "always": worktree, error if not a git repo root. "never": always the live host dir. Worktrees live in .worktrees/<instance-id>.folders/, are detached at the base checkout’s HEAD (no branch created), and survive rebuild while the entry’s key and path stay the same (under "auto" a rebuild keeps whichever mount the instance had; switching to "always"/"never" applies on the next rebuild); worktree-include/worktree-link don’t apply to them. |
autostart |
true | false | "runtime" |
Once per host boot (per config root), the host daemon’s start (devsandbox serve), the first devsandbox run / start or TUI launch, whichever comes first, starts this sandbox’s stopped instances (full start path), or runs one if it has none; dispatcher children are never started and count as existing (a sandbox with only children gets no run); containerless instances are skipped with a note; while the runtime is unreachable the pass is skipped and retried on the next trigger. Any other value errors. "runtime" does the same and also creates the container with --restart unless-stopped on docker/podman (podman restarts at boot only with podman-restart.service enabled; devsandbox never installs it), so the runtime restarts the container itself. An in-container boot hook then brings back the helper daemon and re-runs postStartCommand (as remoteUser, in workspaceFolder, with remoteEnv; output appended to /run/devsandbox/boot.log) on every container start, so devsandbox start doesn’t run it from the host; run still does at create. Host-side bridges, forwards and control resume once the host daemon runs: the first devsandbox command that needs it starts it, or the service manager does at login after devsandbox serve install. Containers created before the hook keep the old behavior and warn on start: recreate with devsandbox rebuild --force <instance>. Apple container has no restart policy: warning, behaves as true. Not hashed for drift: start updates an existing container’s restart policy in place. |
dispatcher |
{ spawn = [string], max-instances = int } |
Makes this sandbox a dispatcher: its instances may create/start/stop/remove child instances through the in-container control API (devsbd ensure|ls|branches|stop|rm|done|exec|run …, served by the host daemon devsandbox serve, started on demand; see docs/automations-guide.md). spawn: sandboxes of this config root it may instantiate, "*" = every non-dispatcher sandbox of this root (granting the dispatcher their mounts, docker socket and other privileges); a sandbox that declares dispatcher is never spawnable, even when named (children can’t be dispatchers; a request from a child is denied too); each other entry must name an existing sandbox (devsandbox ls errors otherwise); omitted = empty (nothing may be spawned). Arrays concatenate under extends like any other. max-instances (optional, default 10): cap on the dispatcher’s children recorded in state (stopped ones count). A child’s --env K=V (control API) may not name PATH, HOME, SHELL, USER, ENV, BASH_ENV, IFS, CDPATH, PS4, PROMPT_COMMAND, SSH_AUTH_SOCK, TMPDIR, GCONV_PATH, NODE_OPTIONS, RUBYOPT or anything starting LD_, DYLD_, GIT_, PYTHON, PERL5 (case-insensitive; denied). Unknown keys error (e.g. max_instances). Children are ordinary instances named <sandbox>-<key> (key [a-z0-9][a-z0-9-]{0,39}), always on their own git worktree, labelled devsandbox.dispatcher=<dispatcher instance id> and recorded with that owner in state.toml; a dispatcher only reaches its own children. Children are kept when the dispatcher is stopped/removed/rebuilt. Re-read from config on every request and not hashed for drift. Grants only the child ops: Inbox threads and events need inbox (below). |
inbox |
bool | true: this sandbox’s instances may own Inbox threads: devsbd thread put|send|withdraw|rm|ls and devsbd events / events --follow / events ack for their own threads (see docs/automations-guide.md, Inbox threads). Without it a thread put/send/withdraw/rm becomes an error notification (thread put denied: …, thread send denied: …, …) and thread ls/events are denied (exit 77); plain devsbd notify never needs it. Independent of dispatcher, which grants only child ops: a dispatcher that wants threads declares both, and a dispatcher’s child may declare inbox to own threads of its own. A running instance with inbox = true keeps the host daemon up, like a dispatcher. Default unset (= false); a scalar, so under extends the most-derived value wins. Re-read from config on every message and request and not hashed for drift. |
Implemented devcontainer properties
| key | notes |
|---|---|
image |
Base image. Exactly one of image / build is needed. |
build |
{ dockerfile (required), context = ".", args = {}, target, cacheFrom = str|[str], options = [str] }. Tag devsandbox-img-<sandbox>. cacheFrom ignored with a warning on runtimes without support. |
features |
{ "<ref>" = { opts } | "version" | true | false } (false skips it, handy to disable an inherited feature). <ref> is an OCI ref (ghcr.io/devcontainers/features/node:1) or a local folder starting with ./, ../ or /, resolved against the config dir, holding devcontainer-feature.json + install.sh (e.g. [template.base.features."./features/toolkit"]); a missing folder or metadata file is flagged by ls. No https/tarball refs. Ordered by installsAfter, baked into devsandbox-img-<sandbox>-feat. Feature-declared mounts are applied, but a sandbox mounts entry on the same target replaces them, and one the host or runtime can’t satisfy (missing bind source, single-file bind on Apple container) is skipped with a warning, as is one still holding an unsupported ${…} variable. A feature declaring privileged: true runs the container --privileged (skipped with a warning on Apple container, which has no such flag). Feature entrypoints run at every container start, in install order, before the image’s own ENTRYPOINT (a failing one warns in the logs and the rest continue; see docs/architecture/instances/images-and-features.md). Feature capAdd is ignored. |
workspaceFolder |
Container path of the working tree. Default /workspaces/<folder basename>. ${…} vars allowed. |
mounts |
Docker shorthand "source=…,target=…,type=bind[,readonly]" or { type = "bind", source, target, readonly }. Default type bind (needs source). One mount per target: the last entry wins (see merge rules). Missing bind sources are auto-created: a basename with a dot after any leading dots (creds.json) → file, otherwise → dir (.zshrc becomes a dir). A volume whose source uses ${instance} / ${devcontainerId} is per-instance: rm deletes it (rebuild keeps it); other named volumes are shared and never deleted. |
containerEnv |
docker run -e. Not ${…}-substituted. |
remoteEnv |
Applied on every exec and lifecycle command. |
containerUser |
docker run --user. |
remoteUser |
exec -u for exec, lifecycle commands, VS Code attach. |
init |
true → docker run --init. |
privileged |
bool; true → docker run --privileged. OR-ed with any enabled feature’s privileged, so false can’t veto a feature that needs it. Skipped with a warning on Apple container (no such flag). |
initializeCommand |
Runs on the host, cwd = config dir, before anything is created. |
onCreateCommand, updateContentCommand, postCreateCommand, postStartCommand, postAttachCommand |
Run in that order in the container after create (as remoteUser, in workspaceFolder, with remoteEnv). start re-runs only postStartCommand. |
customizations.vscode.extensions |
[string]; installed via VS Code (workspace recommendations on Apple container). |
forwardPorts |
[int | string]; forwarded to 127.0.0.1 on the host by the host daemon (devsandbox serve) for each running instance, dashboard open or not (tunneled like devsandbox port, no -p). Entries [host:][service:]port: 3000 / "3000" (the instance’s port), "db:5432" (a service’s port; must be in the sandbox’s services), and the devsandbox extensions "8080:3000" / "15432:db:5432" (host port to start from). A leading number is always the host port. The host port is the target port (or the given one) if free, else the next free one up (100 tried), skipping ports held by other instances’ forwards; the choice is saved per instance in state.toml (forwarded_ports; global services once per config root) and reused next time. A global service’s entry is forwarded once per config root however many instances declare it. Duplicate entries (after extends concatenation) are dropped. Anything else errors. Not hashed for drift. |
Lifecycle command forms: "shell string" (→ sh -c), ["argv", "..."], or a table { name = "cmd" | [argv] } — the named form runs sequentially, in key order, not in parallel.
Accepted but ignored (warned at run)
name, appPort, portsAttributes, otherPortsAttributes, runArgs, workspaceMount, overrideFeatureInstallOrder, updateRemoteUserUID, userEnvProbe, overrideCommand, shutdownAction, capAdd, securityOpt, hostRequirements, waitFor, secrets, customizations.vscode.settings, any other customizations.vscode.* or customizations.<tool>.
Not accepted at all (hard error): compose keys (dockerComposeFile, service, runServices) — compose support was removed.
${…} variables
Substituted in mounts (source/target), workspaceFolder, worktree-branch, shell-rc, and folders (keys and values). Unknown expressions are left verbatim.
| var | value |
|---|---|
${configDir} |
absolute config dir |
${sharedVolumes} |
${configDir}/shared-volumes |
${localWorkspaceFolder} |
absolute host folder (the base repo, even for worktree instances) |
${localWorkspaceFolderBasename} |
its basename |
${instance} |
persistent instance id (name at creation; stable across renames) |
${devcontainerId} |
same as ${instance} (devcontainer’s stable per-container id; features name volumes with it) |
${localEnv:VAR} |
host env var, empty if unset |
Services ([services.<name>])
Unknown keys error. No extends for services.
| key | type | meaning |
|---|---|---|
scope |
"isolated" (default) | "global" |
isolated: one container per instance on a per-instance network; global: one container per config root. |
image / build |
as sandbox | exactly one required. |
env |
{string = string} | -e vars. |
ports |
[string] | docker run -p specs ("5432", "5432:5432", "127.0.0.1:5432:5432"). A bare container port ("5432", "53/udp", "127.0.0.1::5432") gets the same host port when it’s free, else one picked by the runtime. An explicit host port already published by another container errors. |
command |
string | [string] | Appended after the image. A string is passed as one argv item, not shell-split. |
Sandboxes reach a service by its name as hostname (network alias, or /etc/hosts on runtimes without aliases). devsandbox gc reaps unreferenced containers; devsandbox service rebuild <name> recreates and rewires in place, without restarting the sandboxes.
Drift
Each container is labelled with config_hash (hash of the merged sandbox table, or the service table) and build_hash (hash of the dockerfile contents). Any change to the merged config — including via a template — marks existing instances as drifted (devsandbox rebuild <instance>), except autostart, dispatcher, inbox and forwardPorts, which are stripped before hashing. A folders table that only restates the default ({ path = "x" }, worktree = "auto") hashes like the plain string "x"; "always"/"never" count as drift. For a sandbox, build_hash also covers the contents of its enabled local-folder features (every file, by relative path), so editing a local feature’s install.sh is drift too. The build context and OCI feature contents are not hashed.
Checking a config
devsandbox -C <dir> lsresolves and validates every sandbox (merge + types) without touching docker. It does not check thatfolder,shell-rcorfolderspaths exist, or thatservicesnames are defined —rundoes.- The TUI config explorer shows both the original and the resolved (merged) table for a sandbox.
Example
[template.base]
init = true
remoteUser = "root"
worktree-branch = "me/${instance}"
caches = ["pnpm", "cargo"]
persist-shell-history = true
mounts = ["source=${sharedVolumes}/gh,target=/root/.config/gh,type=bind"]
[services.postgres]
image = "postgres:16"
env = { POSTGRES_PASSWORD = "postgres" }
[sandbox.web]
extends = "base"
folder = "../web"
build = { dockerfile = "dockerfiles/web.Dockerfile" }
services = ["postgres"]
containerEnv = { DATABASE_URL = "postgres://postgres:postgres@postgres/postgres" }
postCreateCommand = "pnpm install"
customizations.vscode.extensions = ["dbaeumer.vscode-eslint"]Source: skills/config-toml-spec/SKILL.mdEdit this page on GitHub(opens in a new tab)