Manifest Reference
stack.toml lives in your project root and is meant to be committed — it's the whole "share a stack" file, the non-container equivalent of a Dockerfile. Every field below is optional unless marked required; a bare stack.toml with just a [project] section is valid.
[project]
| Field | Type | Default | Notes |
|---|---|---|---|
| name | string | folder name | Used to key stack status/stack logs/stack down and as the default domain prefix. |
| domain | string | {name}.localhost | .localhost resolves with zero setup in Chrome/Firefox/Edge — no hosts-file edit. Non-browser clients (curl, backend-to-backend calls) fall back to 127.0.0.1:<port>. |
| env_files | array of strings | [] | Extra .env paths, relative to the project root, loaded in addition to a root-level .env (if present) by stack up, stack doctor --project, and stack load-env when it's called with no explicit path. For a project whose real .env lives somewhere other than the project root. |
[project] name = "acme-api" domain = "acme-api.localhost" env_files = ["backend/.env"] # optional — root .env still loads too, if present
[[clone]]
Array of tables. Lets a bare stack.toml — the only file someone needs to bootstrap your whole project — declare which git repos to fetch. Only runs when stack up is given --clone; plain stack up never touches git, so cloning is something you opt into and can review, not a side effect that happens silently on every run.
| Field | Type | Default | Notes |
|---|---|---|---|
| repo | string | required | Anything git clone accepts (SSH or HTTPS). |
| path | string | derived from repo | Where it's cloned to, relative to the project root. Unset, it's git's own default naming — the repo URL's last path segment with .git stripped, so git@github.com:acme/api.git lands in ./api. Set it to rename the folder, or to "." to clone directly into the project root (see below). |
| ref | string | default branch | Branch, tag, or commit SHA — cloned, then checked out separately (checkout accepts all three; --branch alone doesn't). |
[[clone]] repo = "git@github.com:acme/api.git" # -> ./api, git's own default naming [[clone]] repo = "git@github.com:acme/shared-lib.git" path = "vendor/shared-lib" # explicit override
Cloning into the project root
Someone shares just a stack.toml with path = ".". They drop it in an empty folder and run stack up --clone — but that folder already has stack.toml in it, so it isn't actually empty, and git clone refuses to clone into a non-empty directory. stack handles this case specifically: instead of git clone, it runs git init + git remote add + git fetch + git checkout in place. If the fetched repo also tracks its own stack.toml, checkout fails loudly on the real conflict rather than silently picking a winner — that's the correct outcome, not a bug to work around.
Running --clone more than once
Safe. An entry with an explicit or derived path is skipped once that directory exists. The path = "." case checks for a .git folder instead (since the project directory itself always exists, trivially) — already-initialized is left untouched either way.
[language.<name>]
Either a bare version string, or a table when you need more control — both forms can coexist as siblings, same as Cargo.toml's [dependencies].
[language] php = "8.3.1" # simple form node = "20.11.0" [language.rust] # detailed form version = "1.75.0" manager = "vfox"
| Field | Type | Default | Notes |
|---|---|---|---|
| version | string | required unless path set | |
| manager | string | inferred | php/node → vfox, python → uv. Any other name needs manager set explicitly. |
| plugin | string | the table key | vfox plugin name override, for the rare mismatch (e.g. Node's plugin is named nodejs). |
| binary | string | {name}.exe | Binary filename override. |
| path | string | — | BYO — a fixed, already-resolved binary. Bypasses vfox/uv entirely. |
| workers | number | 4 | PHP only. Sets PHP_FCGI_CHILDREN for stack's default FastCGI execution (see below) — how many concurrent php-cgi worker processes handle requests for this project. Raise it for a project doing real concurrent local load-testing; the default of 4 is plenty for normal day-to-day browsing. |
| venv | string | — | Python only, in practice. A virtualenv directory, relative to the project root — its Scripts (Windows) folder is added to PATH during activation, right alongside the interpreter itself. Replaces a manual .venv\Scripts\activate step: every console-script your venv installed (pytest, black, a Django manage.py-adjacent tool, ...) just resolves once you cd in, the same as everything else stack manages. |
Every project pinning the same language+version shares the one binary from vfox's/uv's own store — nothing is copied per project. Activation (see Everyday use) puts it first on PATH the moment you cd in.
[service.<name>]
| Field | Type | Default | Notes |
|---|---|---|---|
| version | string | required | Also the key every other project shares an instance by — same engine+version reuses the one running process. |
| schema | string | project name | Project isolation is by schema/database name, not a separate data directory per project. |
| port | number or "{VAR}" | engine default | 3306/5432/27017 for mysql/postgres/mongo; required explicitly for any other engine name. A quoted "{VAR}" resolves from the environment (or an interactive prompt with --prompt) the same way command placeholders do — see below. |
| command | string | engine default | Built in for mysql/postgres/mongo; required for anything else. {port}/{data_dir}/{path} placeholders available. |
| path | string | — | BYO binary, this project only. Omit to resolve by version via stack register instead. |
| external | bool | false | Adopt an already-running instance — stack only verifies it's listening, never starts/stops it. Errors if path/command are also set, or if nothing's actually listening. |
[service.mysql] version = "8.0.35" schema = "acme_api" # optional — defaults to the project name
[run]
The one process that needs a stable, routed domain — usually your app's own dev server. Omit [run] entirely and stack still activates languages and starts declared services; it just prints that nothing is routed.
| Field | Type | Default | Notes |
|---|---|---|---|
| command | string | required unless external, or [language.php] is declared | {port} is substituted; PORT is also set as an env var, for anything reading process.env.PORT. See below if omitted with PHP declared. |
| port | number or "{VAR}" | ephemeral | Pin it if anything (OAuth callback, CORS allowlist, a proxy target) hardcodes the port — stack errors clearly rather than silently picking a different one when it's taken. A quoted "{VAR}" resolves like a command placeholder — see below. |
| cwd | string | project root | Which cloned path the command runs in — matters once [[clone]] has more than one entry. |
| external | bool | false | Run the dev server yourself, in your own terminal — stack only validates the port and routes to it, never spawns or tracks the process. port becomes required. See below. |
Omitting command for PHP
When [language.php] is declared and command is left out, stack defaults to its own FastCGI execution: php-cgi.exe with real concurrent worker processes, fronted by a Caddy FastCGI route, doc-root auto-detected (public/ if it has an index.php, otherwise the project root). This is the same "sensible default when omitted" pattern [service.*] already has for mysql/postgres/mongo, just for [run]. Set command explicitly (e.g. php -S 127.0.0.1:{port} -t public) if you need something other than FastCGI, or if the install is non-standard and doesn't ship php-cgi.exe next to php.exe.
Why [run].external exists
Sometimes you'd rather run the dev server yourself — watch its own output directly in your own terminal, keep whatever reload workflow you already use, or just not hand the process over to another supervisor. external = true is built for that: stack's only job becomes routing a real domain to the port you tell it about.
[run] external = true port = 8000
Why the first stack up on a project asks you to confirm
command here (and every [service.*].command) is a literal program invocation — stack runs it exactly as written. The first time a project's commands are seen, or the first time they change, stack up prints them and asks you to confirm before anything actually executes; declining aborts cleanly with nothing spawned. Approval is remembered in ~/.stack/trust.json, so unchanged commands never prompt again on later runs. Pass --yes to approve without the interactive prompt, for CI or scripted use. Full detail in the CLI Reference's stack up entry.
[tool.<name>]
| Field | Type | Default | Notes |
|---|---|---|---|
| path | string | — | BYO — any tool you've already installed. Its directory is injected into the [run] process's PATH. |
| version | string | — | For the small set of tools stack knows how to fetch itself (Composer today), or resolved via stack register for anything you've registered globally. |
[script.<name>]
Named, project-scoped commands — migrations, tests, seed scripts — run via stack run <name> in the foreground, not started/stopped like [run]/[service.*]. Gets the same resolved language PATH (and venv, if declared) as stack up, so a step can just call python/pytest/etc. directly.
| Field | Type | Default | Notes |
|---|---|---|---|
| command | string or array of strings | required | A single command, a TOML array of steps, or a multi-line string (one step per line) — run in order, stopping at the first step that exits non-zero, with that step's exit code. stack run <name> -- extra args forwards trailing arguments to the last step only. |
| cwd | string | project root | Same meaning as [run].cwd. |
[script.migrate] command = "python manage.py migrate" # single step [script.reset-db] command = [ # multiple steps, array form "python manage.py flush --no-input", "python manage.py migrate", "python manage.py loaddata fixtures/dev.json", ]
Like [run]/[service.*].command, a script's commands go through trust-on-first-use before they can execute — checked lazily, only when that specific script is actually run via stack run, not bundled into stack up's check. See the CLI Reference's stack run entry.
Placeholders
Any {VAR_NAME} inside a command string resolves in order: reserved keywords ({port}, {data_dir}, {path}) first, then the process environment (including a project's own .env, loaded automatically before resolution), then — only with stack up --prompt — an interactive prompt, masked for anything that looks like a secret. Nothing left unresolved fails silently: every unresolved name is reported together, not one at a time.
A port field can be a quoted placeholder too — e.g. port = "{MEILISEARCH_PORT}" — resolved the same way (environment, then prompt) and parsed as a number afterward. Reserved keywords aren't available here, since port is itself the thing being resolved; an unquoted number always skips resolution entirely.
Putting it together
A single manifest combining most of the above — two languages, one managed service, one external service, a clone entry, and a custom [run] command:
[project]
name = "acme-api"
domain = "acme-api.localhost"
[[clone]]
repo = "git@github.com:acme/acme-api.git" # -> ./acme-api
[language]
node = "20.11.0" # simple form: no other options needed
[language.php] # detailed form: php needs workers set, so it gets its own table
version = "8.3.1"
workers = 8 # raised from the default 4 for a local load test
[service.mysql]
version = "8.0.35"
schema = "acme_api"
[service.redis]
version = "7.2"
external = true
port = 6379
[run]
command = "php -S 127.0.0.1:{port} -t public"
port = 8000
cwd = "acme-api"
A language can use the simple string form or the detailed table form, never both for the same name — [language]'s php = "8.3.1" and a separate [language.php] table would collide on the same TOML key and fail to parse. Once you need any detailed-form field (here, workers), version moves into that same table.