One command. A fully configured development environment.
booth config creates a complete .booth/ configuration — Boothfile, config, startup scripts — from templates. Browse and select interactively via the TUI or the Web UI, or use --no-tui for scripting. Users can add their own startup scripts to .booth/startups/ that survive re-generation. No manual Dockerfile writing required.
# Interactive TUI
./booth config
# Same editor in a browser, on the booth port
./booth config --web
# CLI mode
./booth config --no-tui --select go+linter/python:3.13+uv/claude-codeFor the full TUI guide, see booth config — Interactive Configuration. For the browser UI, see booth config — Web UI.
Back to README
- Overview
- Modes
- Selection DSL
- Selection Sources
- Flags
- Setting config values
- Browsing Templates
- What Gets Generated
- Hand-Written Files
- Run-Args Ownership Convention
- Common Workflows
- Package Management Templates
- Editor Extensions
- Binary companions — library X → also select Y
Setting up a CodingBooth environment means creating a .booth/ folder with a Boothfile, config.toml, and possibly startup scripts. For a simple single-language project this is straightforward, but for a polyglot project with multiple languages, AI tools, and IDE extensions, writing it all by hand is tedious.
booth config solves this with template-driven scaffolding. You select what you need — languages, tools, extensions — and config compiles everything into a ready-to-use .booth/ configuration. Templates encode best practices (correct ordering, proper arguments, volume persistence) so you get a working environment without needing to understand every detail.
For more information on how setup scripts are generated and structured, see the Booth Setup Guide.
Opens an interactive terminal interface for browsing templates and configuring your booth. This is the default when you run booth config.
# Open TUI from scratch
./booth config
# Open TUI with templates pre-selected
./booth config --select go+linter --variant codeserver
# Edit an existing booth — reads .booth/Boothfile and pre-populates the TUI
./booth config ./existing-projectSee booth config — Interactive Configuration for the full TUI guide.
Opens the same editor in a browser on the booth port (http://127.0.0.1:10000
by default). Same catalog, same Config-tab fields, same save path as the TUI.
Loopback only, with a one-shot token in the URL.
./booth config --web
./booth config --web --select go+linter --variant codeserverIf the booth port is already taken, the command refuses — stop the running
booth, or pass --port. See booth config — Web UI.
Non-interactive mode for scripting and quick setup.
# Create a new booth configuration
./booth config --no-tui --select python+uv
# In a target directory
./booth config --no-tui ../my-project --select go+linter/python:3.13+uv
# Empty booth in a fresh directory (no templates, just CLI overrides)
./booth config --no-tui --variant codeserver --port 10080If files config generated earlier already exist, it prompts for confirmation before overwriting them. Use --overwrite to skip the prompt.
If the Boothfile or config.toml is hand-written, config refuses outright rather than prompting — re-generating would destroy work it cannot reproduce. See Hand-Written Files.
Both modes read an existing .booth/ as their baseline, and the flags of this
run override it. The baseline comes from the # Configured by: header in
.booth/Boothfile (the selection, --variant, --port, --cmd, --set) and
from .booth/config.toml (the long-form --env / --volume / --publish
run-args, plus the cache-* / shared-* paths that no template of the recorded or the new
selection declares — a template's own paths come back from the selection, so a
deselected template's leave with it, while a path you added that only matches some
unselected template stays yours; when the recorded selection read stdin or a recipe, every
template's paths count, since it cannot be rebuilt as it was).
A booth that selects no templates has
no Boothfile, only config.toml — which carries the same header, so the baseline is
read from there instead.
So a reconfigure only has to state what changes:
# Add an extension. The variant, ports, envs, mounts and version pins all stay.
./booth config --no-tui --overwrite --select go+linter/python:3.13+uvOmitting a flag keeps the recorded value; restating a list flag (--select,
--env, --mount, --expose) replaces the whole list rather than adding to
it — which is one way to remove an entry, at the cost of restating the rest:
# Was: --env FOO=1 --env BAR=2. Now only BAR survives.
./booth config --no-tui --overwrite --env BAR=2--select, --expose, --env, and --mount each have an --add-* and a
--remove-* counterpart that edits the baseline in place instead of replacing
the whole list, so you don't have to restate everything else:
# Add a template or an extension, keeping everything else selected.
./booth config --no-tui --overwrite --add-select docker
./booth config --no-tui --overwrite --add-select go+linter # adds `linter` to the
# already-selected `go`
# Drop one template or extension by name.
./booth config --no-tui --overwrite --remove-select python
# Add or update one expose/env/mount entry, keyed on the part that identifies it —
# the container port, the env KEY, the container path — so re-adding an existing
# key updates it rather than duplicating it.
./booth config --no-tui --overwrite --add-expose 9000:8080 # was exposing 8080; now on 9000
./booth config --no-tui --overwrite --add-env FOO=2 # was FOO=1
./booth config --no-tui --overwrite --add-mount /other:/app/data
# Drop one entry by that same key — or by the whole entry, which must then match.
./booth config --no-tui --overwrite --remove-expose 8080
./booth config --no-tui --overwrite --remove-env FOO
./booth config --no-tui --overwrite --remove-env FOO=1 # only if FOO is 1
./booth config --no-tui --overwrite --remove-mount /app/data--remove-* and --add-* for the same field can combine in one run — a
--remove-select go --add-select go:1.25 replaces go's params by dropping it
and adding it back, in one step.
A removal that matches nothing is an error, and nothing is written. That is almost always a typo or a stale value, and carrying on would leave the booth holding what you meant to drop. When the key exists with a different value, the error shows the recorded entry:
Error: --remove-env "FOO=2" did not match anything in the existing booth (it has "FOO=1")
Nothing was written.
Combining a field's plain (replace) flag with its own --remove-* in the same
run is refused: the plain flag already discards the whole baseline for that
field, so there is nothing left for --remove-* to act on. Combining the plain
flag with --add-* is fine — the add applies onto the value just given, not the
discarded baseline.
There is no --add-cmd/--remove-cmd: --cmd holds one command's argv, not an
unordered set of commands, so restating it is the only way to change it — the
same reason Setting config values below never merges
cmds on --set, and a profile overlay never merges it either.
The flags that steer the run itself — --overwrite, --beside, --dryrun,
--start — are never inherited from the header, even though the header records
the --overwrite used to write it. Each run decides those for itself.
To start over rather than reconfigure, delete .booth/ first.
Preview what would be generated without writing any files. Works in both modes.
# CLI dryrun
./booth config --no-tui --dryrun --select go+linter/python:3.13
# TUI dryrun — opens TUI, prints output on confirm instead of writing files
./booth config --dryrun --select go+linterA CLI dryrun also checks that the existing .booth/ can be loaded, and fails exactly where a real
run would: an unknown template, or hand-written files without --overwrite
or --beside. Exit 0 means the same command without --dryrun would write what it printed. With
--overwrite or --beside, it notes on stderr which hand-written files would be backed up or kept.
The --select flag accepts a mini-language for specifying templates, versions, extensions, and exclusions.
Separate multiple templates with /:
go/python/claude-code
Append : to set a template's version parameter:
python:3.13
go:1.25
java:21
Multiple parameters use commas: java:21,temurin. Comma values are positional —
playwright:chromium,1.58.2 sets the browsers (PLAYWRIGHT_BROWSERS=chromium)
and the Playwright package version (PLAYWRIGHT_VERSION=1.58.2). Pin the
Playwright version so the pre-baked browsers match the Playwright that npm ci
installs at runtime; it defaults to latest.
Version parameters compile to arg NAME=VALUE lines in the generated Boothfile
(e.g. python:3.13 → arg PYTHON_VERSION=3.13). These pins are preserved
across re-generation: re-running booth config --overwrite to add or remove an
unrelated template keeps every non-default pin already in the Boothfile, unless
the new selection explicitly overrides it (an explicit :value always wins). So
adding a template no longer silently resets a pinned NODE_VERSION,
PLAYWRIGHT_VERSION, etc. back to its template default. The TUI likewise
pre-loads the real pinned values from the existing Boothfile into its param
fields.
A param whose default follows another param (see Ports)
is not preserved this way when its value was merely derived. arg lines hold
resolved values, so a followed host port reaches the Boothfile as a number and cannot be
told from a pin by comparing it to "${SVC_PORT}". The old value is re-resolved against
the Boothfile's own args to reconstruct what the default came to last time: a match is
derived and re-derives from the new selection, anything else is a real choice and
survives. So re-configuring openssh+server:2200+expose to openssh+server:22+expose
publishes 22:22, while a pinned +expose:2222 still publishes 2222 afterwards.
Append + to add extensions:
python+uv+pip+kernel
go+linter+vscode-ext
java+maven+lombok
Extensions take parameters the same way templates do — positionally, after :. The
service port and the port it is published on are separate params, and they compose:
openssh+server:2200+expose # sshd on 2200, published on 2200
openssh+server:22+expose:2222 # sshd on 22, published on 2222
rabbitmq+start+expose:15672,25672 # AMQP on 15672, management UI on 25672
rabbitmq+start+expose:+4567 # AMQP published on offsetBase + 4567
Every service has exactly one param for the port it listens on, and its expose
extension has one for the host port it is published on. The host port defaults to
"${SERVICE_PORT}" — a reference, not a copy — so moving the service moves the
published port with it, and the two cannot drift apart:
| Selection | Publishes |
|---|---|
cloudbeaver+expose |
20300:20300 |
cloudbeaver:25.3.5,9000+expose |
9000:9000 — host follows the service |
cloudbeaver:25.3.5,9000+expose:19000 |
19000:9000 — host overridden alone |
cloudbeaver+expose:+8978 |
28978:8978 — host relative to an offset base of 20000 |
Override the host port only when the host port is taken but the booth's should not
move. The databases whose server has no port param of its own (postgresql, mysql,
redis, mongodb, nginx, apache — each listens on its stock port inside the
container) have only the host-side knob, on the expose extension.
A host port written as +OFFSET is relative to the offset base: it stays literal in
run-args and is resolved at container start as offsetBase + OFFSET. The base is the
booth port unless the booth sets one of its own — see Moving the base below. With a
booth port of 20000 and no base set, rabbitmq+start+expose:+4567 writes "-p", "+4567:5672" and publishes AMQP on 24567. A bare number is still an absolute host port —
the + is what makes it relative.
This is the same +OFFSET form --expose +4567:5672 and hand-written run-args already
use; the offset belongs on the host side only. Because it derives from the booth port,
two booths of the same project on different ports no longer collide on published ports.
--offset-base <n> (also CB_OFFSET_BASE, or offset-base in config.toml) decides what
+OFFSET counts from, without touching the booth port. Following the booth port is the
right default when several booths share a host and their published ports have to stay out
of each other's way. It is the wrong one for a booth that owns the machine: there the port
range is all its own and the front door sits wherever the platform put it, so services
should count from a base you chose.
booth --port 443 --offset-base 20000 # front door on 443, +4567 publishes on 24567
booth --offset-base 0 # +8090 publishes on 8090 — offsets are absoluteA base of 0 is legal (a booth port of 0 is not) and is the way to make a config written
in offsets publish at stock ports unchanged. The duplicate-host-port check below runs after
resolution either way, so a moved base that lands a service on the booth's own port is
still refused rather than handed to docker.
The + survives the selection DSL even though + also separates extensions: inside a
param list, a + starts an extension only when followed by a letter, and names are never
digit-initial. So expose:+4567+start is a relative port and a +start extension.
A host port written as ${NAME} or ${NAME:-digits} is expanded from the host
environment at booth start (see Booth Variable Expansion). The
expression is stored literally in run-args; only the host side may use it — the
container port stays a number.
postgresql+expose:${POSTGRES_PORT:-15432} # -p ${POSTGRES_PORT:-15432}:5432
--expose '${SERVER_PORT:-12345}:1234' # free-form publish
--expose '${APP_PORT:-3000}' # bare → HOST:HOST
Quote the value so your shell does not expand it at config time. At start:
booth # host 15432 → container 5432
POSTGRES_PORT=25432 booth # host 25432 → container 5432The fallback itself may be base-relative. Write ${NAME:-+OFFSET} and the
default is not a fixed number but offsetBase + OFFSET — the env var wins when set,
otherwise the port follows the base, which is the booth port unless --offset-base
moved it (the two host-side forms composed):
--expose '${SERVER_PORT:-+300}:1234' # SERVER_PORT if set, else offsetBase+300
booth --port 10000 # host 10300 → container 1234
SERVER_PORT=25000 booth # host 25000 → container 1234A +OFFSET fallback needs an explicit :CONTAINER; the bare HOST:HOST shorthand
cannot carry an offset on the container side, so --expose '${SERVER_PORT:-+300}'
is rejected. This works because ${…} is expanded (at TOML unmarshal) before
+OFFSET is resolved against the offset base — expansion yields +300:1234, then
the offset step rewrites it.
Three knobs, three jobs. The booth port (--port / CB_PORT) moves the primary
UI mapping, not every service publish. The offset base (--offset-base /
CB_OFFSET_BASE) moves what every +OFFSET counts from, without touching the booth
port. And a host-env port overrides one service alone. So: to follow the base,
use +OFFSET; to override one service's host port from the environment, use
${NAME:-digits}; to do both — env override with a base-relative default — use
${NAME:-+OFFSET}.
A param default may reference another param by name (default = "${SVC_PORT}"), and
references are resolved before anything consumes them, including transitively. A
circular reference is a config-time error. A ${NAME} that is not a param — ${HOME},
or a host-env port like ${SERVER_PORT:-12345} — is left alone for expansion at runtime.
--expose adds a mapping; it does not replace what the +expose extension
contributes. Selecting cloudbeaver+expose and passing --expose 19000:8978 publishes
container port 8978 twice — on 8978 and on 19000 — and the first one stays bound, which
is the port you were presumably trying to get off. booth config says so:
Note: a selected template already publishes container port 8978 as "8978:8978".
--expose 19000:8978 adds a second mapping; it does not move the first, which stays bound.
To move it, give the expose extension the host port instead (e.g. +expose:19000).
Use +expose:19000 to move the host port. Reach for --expose only for what the
extension cannot express: IP binding (--expose 127.0.0.1:19000:8978), or publishing a
port no template owns.
Docker cannot bind a host port twice — it fails the container with address already in use — so a config that publishes one twice never starts. Two spellings of the same
mapping are collapsed rather than published twice, whether they came from a template
plus a --expose (cloudbeaver+expose --expose 8978:8978) or from two templates whose
ports resolve alike (nginx+expose/apache+expose, both 8080:80). The user-owned
--publish is the form kept, since that is the one re-read into --expose on
reconfigure.
Anything that genuinely cannot bind is refused at start, naming both mappings, rather
than left to docker's driver failed programming external connectivity:
Error: host port 8978 is published twice — "8978:8978" (run-args) and "8978:9000" (run-args).
That check runs after +OFFSET resolution, so it catches an offset that lands on an
absolute mapping (-p 28978:8978 next to -p +8978:9000 on a booth at 20000), and it
includes the booth's own port — publishing over it is refused too. Publishing one
container port on two different host ports is legal in docker, so it is left alone.
Use ~ to exclude extensions that would otherwise be auto-selected:
firebase~credential
go:1.25+linter+vscode-ext/python:3.13+uv+kernel/notebook/claude-code
This selects:
- Go 1.25 with linter and VS Code extension
- Python 3.13 with uv, and Jupyter kernel
- Notebook variant
- Claude Code AI assistant
A param value that holds a / has to be quoted, or the / starts a new
template. Go module paths are the usual case:
# Right
booth config --no-tui --select 'go+go-pkg:"github.com/fullstorydev/grpcurl/cmd/grpcurl@latest"'
# Wrong — selects go, then templates named "fullstorydev", "grpcurl", "cmd", …
booth config --no-tui --select go+go-pkg:github.com/fullstorydev/grpcurl/cmd/grpcurl@latestThe ambiguity is real, not an oversight: go:1.25.7/claude-code has the same
shape, and there the / genuinely does separate two templates. Quotes are how
you say which one you mean.
Either " or ' works — pick the one that gets past your shell. Wrapping the
whole selection in single quotes and using " inside, as above, is the form
booth config writes into the generated headers.
Each value in a list is quoted on its own; the commas between them still separate:
booth config --no-tui --select 'nodejs+npm-pkg:"@types/node","@types/react"'Only param values are quotable — template and extension names never need it.
And only values that need it: go:1.25.7, cloudbeaver+expose:+19000 and
apt+apt-pkg:libstdc++6 all stay plain, because a + inside a param list
already stays with the value unless a letter follows it, and only the first :
of an item separates (deno+deno-pkg:jsr:@luca needs nothing).
For readability in files and heredocs:
- Spaces around
+and~are allowed:java + mavenworks - Lines starting with
+or~continue the previous template - Remaining whitespace becomes
/separators - Whitespace inside a quoted value is part of the value
| Source | Syntax | Example |
|---|---|---|
| Inline | Direct string | --select go+linter/python:3.13 |
| Multiple flags | Repeated | --select go+linter --select python:3.13 |
| Project recipe | @name |
--select @fullstack → .booth/recipes/fullstack.recipe |
| Path | @/… @./… @~/… |
--select @./shared/stack.recipe |
| URL | @@url |
--select @@codingbooth.io/r.recipe (HTTPS if no scheme) |
| Stdin | - |
--select - (type or pipe) |
Recipe files are plain text with the same DSL syntax — one template per line.
Bare @name looks under the config target’s .booth/recipes/ (.recipe is
appended when missing). Path-shaped refs and @@ URLs bypass that directory.
Project templates are flat files under .booth/templates/<name>.toml. They are
merged into the catalog automatically (local name overrides stock with a
warning) and all list under This project; they need no directory, no
meta.toml, and no display keys. A # outside quotes in a recipe is a comment
to the end of the line. A recipe does not contain another recipe.
| Flag | Description |
|---|---|
--select <dsl> |
Template selection (repeatable) |
--add-select <dsl> |
Reconfigure only: add onto the existing selection instead of replacing it — see Adding or removing one entry (repeatable) |
--remove-select <name> |
Reconfigure only: drop a template or extension by name from the existing selection (repeatable) |
--no-tui |
Non-interactive CLI mode |
--web |
Browser UI on the booth port (127.0.0.1:<port>) |
--dryrun |
Preview what would be generated without writing files; fails where a real run would |
--variant <name> |
Set variant (default, console, terminal, base, notebook, codeserver, xfce, kde) |
--port <port> |
Set port in generated config.toml (number, NEXT[:base], RANDOM[:base]) |
--cmd <command> |
Set the default start command (repeatable) |
--expose <port> |
Expose extra port (HOST:CONTAINER, +OFFSET, or host-side ${NAME:-digits}; produces long-form --publish in run-args; repeatable) |
--add-expose <port> |
Reconfigure only: add or update one expose mapping, keyed on the container port (repeatable) |
--remove-expose <port> |
Reconfigure only: drop one expose mapping by container port (repeatable) |
--env <KEY=VALUE> |
Set container environment variable (produces long-form --env in run-args to distinguish from template-contributed -e; repeatable) |
--add-env <KEY=VALUE> |
Reconfigure only: add or update one env var, keyed on KEY (repeatable) |
--remove-env <KEY> |
Reconfigure only: drop one env var by KEY, or by KEY=VALUE when the value matches (repeatable) |
--mount <host:container> |
Mount volume (produces long-form --volume in run-args to distinguish from template-contributed -v; repeatable) |
--add-mount <host:container> |
Reconfigure only: add or update one mount, keyed on the container path (repeatable) |
--remove-mount <container> |
Reconfigure only: drop one mount by container path (repeatable) |
--set <key=value> |
Set a config.toml value (repeatable; bare key = boolean true). See Setting config values |
--unset <key> |
Reconfigure only: drop a recorded config.toml key (repeatable). See Reconfiguring with --set and --unset |
--version <ver> |
Use templates from a specific release version |
--templates-path <dir> |
Use local templates directory |
--overwrite |
Overwrite existing files without prompting — including hand-written ones, which are kept as <name>.bak (--no-tui only) |
--beside |
Keep hand-written files; write the generated content next to them as <name>.new to merge by hand (--no-tui only) |
--start |
Launch the booth immediately after config |
--debug |
Print resolved selection and compiled output as JSON |
--set writes a key into the generated config.toml. The value is written in the
shape that key expects, so it lands in the booth as the right type:
booth config --no-tui --select go \
--set persist-home \ # bare key → boolean true
--set keep-alive=false \ # explicit boolean
--set timezone=Asia/Bangkok \ # string
--set idle-time=30 \ # integer — written unquoted
--set egress-allowlist=example.com # list — repeat the flag, or separate with commasA bare key means "turn this on", which only reads as an intent for a boolean —
--set timezone is an error, not an empty string.
A list key takes one value per --set, or several separated by commas:
--set egress-allowlist=a.com,b.com is the same as two --sets, and is
recorded in the header as two. The argv lists — run-args, build-args,
common-args and cmds — never split on commas, because a comma is ordinary
text in a docker flag or a command (--mount type=bind,src=…). Each of their
--sets is exactly one token.
On an existing booth, --set replaces only the keys it names. Every other key
recorded in the header is kept:
# Was: --set timezone=Asia/Bangkok --set egress-allowlist=a.com
booth config --no-tui --overwrite --set egress-allowlist=b.com
# Now: --set timezone=Asia/Bangkok --set egress-allowlist=b.comA key given again replaces its whole recorded value, list keys included, so restate every value you want to keep. The header always records the full value, so regenerating from the header alone reproduces the booth.
--unset <key> drops a recorded key: its --sets, the variant/port/cmds
it names, and any cache-*/shared-* paths read back from config.toml (paths
a selected template declares come back from the template):
booth config --no-tui --overwrite --unset timezone --unset cache-dirsUnsetting a key the booth does not record is an error, like a --remove-* that
matches nothing. So is an unknown key, and so is --set and --unset of the
same key in one run.
Only keys booth actually reads are accepted. A typo is refused up front rather than
written into config.toml and silently ignored at every start afterwards:
Error parsing --set: unknown --set key "persist-hom": booth does not read that from config.toml
Did you mean "persist-home"?
The same applies to the value's type — --set idle-time=soon is refused, because an
integer key holding a string produces a config.toml booth cannot load at all.
public, tls-cert and tls-key are not config.toml settings. They are real —
booth --public --tls-cert cert.pem --tls-key key.pem binds the booth on all
interfaces behind a password over HTTPS — but booth reads them only as flags, never
from a file, so they are not something booth config can store:
booth --public # this works
booth config --no-tui --set public # this does not — warns, and is ignoredThat is deliberate. config.toml is committed, so a stored public = true would
expose the booth on every machine that clones the project, while the password it
requires lives in .booth/.booth.password, which is gitignored and therefore absent
for everyone but you.
booth config has no fields for these, and a value recorded by an older version is
dropped on the next reconfigure with a note.
Use booth template to explore what's available before running init.
# List all primary templates
./booth template list
# Search by name, description, or tag
./booth template search python
# Show detailed info (parameters, extensions, tags)
./booth template show go
# Show extension details
./booth template show python+uv
# Show file and segment contents
./booth template show go --detail
# Show raw template code
./booth template cat goUse --full with list or search to include secondary (non-primary) templates.
The v0.80.0--rc1 catalog has 205 templates and 341 extensions across 9 categories: Languages, Frameworks, Middlewares, Tools, AI Tools, IDEs, Desktop, Education, and Booth VM (macOS). Browsers live under Desktop. See the catalog inventory for per-category counts and how to reproduce them.
OCaml is experimental: it ships without tests or an example, so expect rough edges.
--select ocaml installs OCaml 5.4.1 with tool revision 1: Dune 3.24.0, UTop 2.17.0,
OCamlFormat 0.29.0, and opam. --select ocaml:5.4.1,1 pins both parts explicitly. The setup
downloads a checksum-verified bundle from CodingBoothPrebuilt
for the booth's Ubuntu release and CPU. The bundle must be extracted at its fixed path under
/opt/ocaml/; /opt/ocaml-current selects the installed environment.
If the archive is unavailable, setup stops with the requested bundle name. It does not compile
the toolchain locally. To explicitly build another bundle, run CodingBoothPrebuilt's workflow
with tools=ocaml, versions=<compiler>-tools<revision> and the required Ubuntu release and
architectures. opam install <package> inside the booth remains available and may compile
additional project dependencies; it defaults to one build job.
Lean is experimental. It is new, so expect rough edges. There are two examples:
examples/workspaces/lean-example (core Lean, quick to start) and lean-mathlib-example. The
extensions without an example (interact, blueprint, mcp, nvim) are tested by the
tests/complex/boothfile/test-boothfile-lean-* fixtures, which are small working projects too.
| Selection | Does |
|---|---|
lean (lean:4.35.0, lean:nightly) |
elan in /opt/elan (writable), a default toolchain, lake |
lean+toolchain-file (auto) |
bakes the project's lean-toolchain pin into the image |
lean+vscode-ext (auto) |
leanprover.lean4: language server, live errors, infoview |
lean+mathlib-cache |
mounts the host's ~/.cache/mathlib (writable, so booth asks for consent) |
lean+interact |
LeanInteract: drive Lean from Python and Jupyter cells; needs python, and builds the Lean REPL into the image |
lean+mcp |
lean-lsp-mcp: an MCP server that gives Claude Code (or another agent) Lean's goals, errors and search; plus ripgrep |
lean+nvim |
lean.nvim for Neovim: the Lean language server and an infoview window; needs neovim |
lean+blueprint |
leanblueprint: a LaTeX blueprint linked to Lean, built to PDF and to a web page with a dependency graph; needs latex and python |
The project's lean-toolchain file decides which toolchain is used. The template's version is
only a fallback for a project that has no pin yet. Mathlib changes its pin often, so for a
Mathlib project keep toolchain-file selected. If you do not, the first lake call in every new
booth downloads the toolchain again.
To give Claude Code the Lean tools, select lean+mcp with claude-code and add the server to the
project, either with claude mcp add lean-lsp lean-lsp-mcp or with a .mcp.json:
{ "mcpServers": { "lean-lsp": { "command": "lean-lsp-mcp" } } }Run lake build once first: the server starts lake serve in the project.
Left out, on purpose:
- Mathlib in the image. Its prebuilt files unpack to about 7 GB for each Mathlib version, and
the version changes every few weeks. Baking them in would make the image huge and tie it to one
version. They go in
~/.cache/mathlib(shared throughmathlib-cache) and in the project's own.lake/. - A Lean notebook kernel (cells written in Lean). The only one,
lean4_jupyter, has not changed since 2024. For notebooks,lean+interactdrives Lean from Python cells instead. - An education template (Natural Number Game, Mathematics in Lean). These can build on
leanlater. They are not needed to try Lean. - Lean 3. It is no longer maintained, since the community moved to Lean 4. The catalog does not offer it.
booth config creates a .booth/ directory with the following structure:
.booth/
├── config.toml # Runtime configuration (variant, port, run-args, etc.)
├── Boothfile # Build instructions (compiled from templates)
├── startups/ # Startup scripts (one per template segment, plus user scripts)
│ ├── 10-claude-code-auto-accept--startup.sh # Generated from template
│ ├── 50-my-custom--startup.sh # User-added (survives adjust)
│ └── 65-excalidraw-autostart--startup.sh # Generated from template
├── .gitignore # Protects .booth.password, .env, and .bak/.new merge artifacts
├── .generated # Fingerprints of the files above (see Hand-Written Files)
├── setups/ # Custom setup scripts from templates
├── home/ # Home directory team files
└── home-seed/ # Home directory defaults
On a project's first configure (no Boothfile or config.toml yet), config also writes a starter
Justfile at the project root, the one booth just-init writes. It skips this when the project
already has a Justfile. After that the file is yours, and config never touches it again. See
USER_GUIDE → Run recipes from the host.
Template-generated startup segments are written as individual files in .booth/startups/, named NN-name--startup.sh where NN is the order number.
Adding your own startup scripts: Create files matching the *--startup.sh pattern in .booth/startups/. Files without a NN- prefix default to order 50. User-added files (without the # Generated by: header) survive re-generation.
Execution order: At container start, all *--startup.sh files in startups/ are sourced in sorted order. Files without a number prefix are treated as order 50.
Legacy: If .booth/startup.sh exists (from older configurations), it is still executed after startups/ scripts for backward compatibility.
Generated files include a header comment showing the exact command used and an adjust command for easy re-generation:
# Generated by: booth config --no-tui --select go/python
# Adjust with : booth config --no-tui --overwrite --select go/pythonbooth config regenerates .booth/Boothfile and .booth/config.toml from scratch every time it writes — it does not edit them in place. Anything a human put in them by hand is therefore something config cannot reproduce from your selection, and re-generating would destroy it.
So it doesn't. Config first works out whether it is looking at its own output or yours.
Every time config writes those two files, it records a SHA-256 fingerprint of exactly what it wrote in .booth/.generated. On the next run it re-hashes what is on disk and compares:
| State | Meaning | What happens |
|---|---|---|
| Fingerprint matches | Config wrote it, nobody has touched it | Regenerated freely |
| Fingerprint differs | Config wrote it, then a human edited it | Read back if config can reproduce the edit; otherwise protected |
No fingerprint, but a # Configured by: header |
Generated before .generated existed |
Adopted — regenerated freely, and fingerprinted from now on |
| No fingerprint, no header | Hand-written from the start | Protected |
The # Configured by: header alone is not enough to decide this: it survives a hand-edit, so a file can carry the header and still be full of someone's own work. Only the fingerprint distinguishes "still ours" from "someone has been here."
.booth/.generated is committed alongside the Boothfile — drift detection has to survive a clone. Delete it and config will treat those files as hand-written again.
It also records which release's template catalog wrote the files (templates=0.78.0), so an edit can be read back against the catalog that produced it — see below. A local catalog (--templates-path / CB_TEMPLATES_PATH) records nothing.
Plenty of edits are ones config could have written itself: a timezone = "Asia/Bangkok" line, an extra --env entry, a changed port, a bumped arg GO_VERSION=. Those are not treated as hand-written. When a generated file's fingerprint no longer matches, config first tries to read the edit back:
- Regenerate what the
# Configured by:header produces, from the template catalog recorded in.booth/.generated. - Lift every difference from the files on disk into flags: a changed or added key becomes
--set(or--port/--variant/--cmd), a run-args entry that no selected template contributes becomes--env/--expose/--mount(short-e/-p/-vforms too), and an editedargpin becomes that template's param in the selection. - Regenerate from the lifted flags and compare with the files on disk — as TOML for
config.toml, directive by directive for the Boothfile. Comments and whitespace do not count; the order of run-args entries and Boothfile lines does.
Only an exact match is read back, so nothing is guessed. The edit then simply becomes part of the configuration: the booth reconfigures without --overwrite or --beside, the TUI opens without a warning and with the edit pre-loaded, and the next save writes it into the header — --set timezone=Asia/Bangkok — so it survives every reconfigure after.
echo 'timezone = "Asia/Bangkok"' >> .booth/config.toml
./booth config --no-tui --overwrite --add-env FOO=1
# Note: .booth/config.toml was edited outside booth config; the edits were read back and are kept.Comments are the exception. They cannot become flags, so a save regenerates the files without them. Config names each comment it is about to remove before writing — the TUI and web editor ask on save, --no-tui prints them (and still asks for confirmation unless --overwrite is given). Only viewing a booth never writes anything, so opening it costs nothing.
Warning: saving regenerates the files, which removes these comments:
config.toml:5 # the team is in Bangkok
When it cannot be read back — a run line no template produces, an unknown key, an entry placed among a template's own run-args (config always writes user entries after them), a template's entry removed — the file stays protected as before, and config says what blocked it:
Could not read back the edits in .booth/Boothfile:
- Boothfile `run echo hi` — no selection or flag produces this line
Move content like that somewhere config copies rather than regenerates — a project template in .booth/templates/, or a script in .booth/setups/ or .booth/startups/ — and it survives every reconfigure.
When a file is protected, you choose what happens to it. Nothing is decided until you do.
Keep yours — the generated content is written next to your file as <name>.new and your file is not touched. You merge the two by hand. Same idea as pacnew / rpmnew / dpkg-dist.
./booth config --no-tui --beside --select go+linter
# → .booth/Boothfile unchanged (yours)
# .booth/Boothfile.new what config would have written
diff .booth/Boothfile .booth/Boothfile.newThe kept file stays protected until you actually merge it — writing .new does not bless your file as config's own.
Replace yours — your file is replaced, and the original is kept as <name>.bak.
./booth config --no-tui --overwrite --select go+linter
# → .booth/Boothfile regenerated
# .booth/Boothfile.bak what was replacedWith neither flag, config refuses and tells you both options — under --dryrun too, so a dryrun is an honest check of whether a reconfigure would go through. The rest of .booth/ (setups/, startups/, home/, cache/) is written normally either way, so merging the .new file is all that remains to complete the reconfigure.
.bak and .new are gitignored — they are merge scratch, not configuration.
The TUI warns as soon as it opens, so you find out before configuring rather than after:
┌──────────────────────────────────────────────────┐
│ ⚠ Warning │
│ │
│ This booth contains hand-written files: │
│ │
│ .booth/Boothfile │
│ │
│ Nothing is decided yet. When you save, you │
│ choose ... │
│ │
│ Go on in and look around — nothing is touched │
│ until you save. │
│ │
│ Enter/Space: continue │ Esc: quit │
└──────────────────────────────────────────────────┘
It is a heads-up, not a blocker — press Enter and configure as normal. On save, Ctrl+S raises the choice: Enter keeps your files and writes .new beside them, and typing overwrite replaces them. See BOOTH_CONFIG_TUI.md.
Templates and users both contribute run-args to config.toml, but they use different flag forms to signal ownership.
Templates use short-form Docker flags in their run-args contributions:
-e KEY=VALUE(environment variable)-v host:container(volume mount)-p hostPort:containerPort(port mapping)
These are added automatically when a template is selected and are considered template-owned.
Values set via --env, --expose, and --mount CLI flags (or the TUI input fields) produce long-form Docker flags:
--env KEY=VALUE--volume host:container--publish hostPort:containerPort
These are considered user-owned.
Docker does not distinguish between -e and --env, or -v and --volume, or -p and --publish. The container behavior is the same regardless of which form is used. The distinction is purely a convention within CodingBooth.
When you re-open booth config on an existing project, the TUI reads config.toml and parses run-args. Only long-form flags (user-owned) appear in the Expose, Env, and Mount input fields. Template-contributed short-form flags are invisible in the TUI because they are automatically re-added when the corresponding template is selected.
This means users only see and edit their own overrides. Template values are managed by the template selection itself.
When a --volume (from --mount / --add-mount or the TUI's Mount field) lands on the same
container path as a template's -v, the template's entry is dropped — Docker refuses two mounts on
one path, and the user's is the one that was asked for. This is how a project swaps a template's
data volume for its own:
booth config --no-tui --add-mount 'myproj-pgdata:/var/lib/postgresql' # replaces booth-pgdata-{name}
booth config --no-tui --remove-mount /var/lib/postgresql # the template's comes backTemplate entries that share a path are left alone: a credential extension lists one host path per platform for the same target on purpose, and the booth keeps the first that exists when it starts.
For a docker flag no dedicated option covers, --set run-args=<token> adds one token to the same
run-args list — repeat it for a flag and its value:
booth config --no-tui --set run-args=--cap-add --set run-args=SYS_PTRACEThe tokens go after the template's and the --env/--expose/--mount ones. The same holds for
every key config.toml writes from its own field (cmds, build-args, sudo, vm-*): a --set
merges into that key and never writes it a second time. On a reconfigure, --set run-args=…
replaces every run-args token the header records, so restate a flag and its value together.
run-args = [
"-e", "PYTHONDONTWRITEBYTECODE=1", # short-form: added by python template
"-p", "2222:2222", # short-form: added by openssh template
"--env", "MY_APP_ENV=development", # long-form: user-set via --env
"--volume", "/host/data:/container/data", # long-form: user-set via --mount
"--publish", "3000:3000", # long-form: user-set via --expose
]When this config is loaded in the TUI:
- The Env field shows
MY_APP_ENV=development - The Mount field shows
/host/data:/container/data - The Expose field shows
3000 - The template-contributed
-eand-pentries do not appear in the fields
./booth config --no-tui --select python+uv
./booth./booth config --no-tui --select go+linter/python:3.13+uv --variant codeserver
./booth./booth config --no-tui --select python:3.13+uv+kernel/notebook/postgresql
./booth./booth config --no-tui --select java:21+maven/claude-code --variant desktop-xfce
./booth./booth config --no-tui --select nodejs --expose 3000 --env NODE_ENV=development --mount /data:/app/data
./boothCreate <project>/.booth/recipes/my-project.recipe (or any path-shaped @ ref):
go:1.25
+ linter
+ vscode-ext
python:3.13
+ uv
+ kernel
notebook
claude-code
Then:
./booth config --no-tui ./my-project --select @my-project
# or: --select @./path/to/my-project.recipe./booth config --no-tui --overwrite --select go+linter/python:3.13+uv/postgresqlVersion pins already in the Boothfile are carried over — you don't need to
re-specify nodejs:22 or playwright:chromium,1.58.2 just because you're adding
postgresql. See Version parameters.
./booth configCodingBooth templates include two types of package management extensions: global tool installation and project dependency pre-installation.
Install tools globally into the image at build time using install <manager> <packages>. These are available via variadic parameter extensions:
# Install global npm packages
booth config --no-tui --select nodejs+npm-pkg:pnpm,typescript
# Install global pip packages
booth config --no-tui --select python+pip-pkg:numpy,pandas
# Install global cargo crates
booth config --no-tui --select rust+cargo-pkg:ripgrep,fd-findThe full list of package manager extensions:
| Extension | Manager | Example |
|---|---|---|
nodejs/npm-pkg |
npm | nodejs+npm-pkg:pnpm,typescript |
nodejs/yarn-pkg |
yarn | nodejs+yarn-pkg:create-react-app |
bun/bun-pkg |
bun | bun+bun-pkg:elysia |
python/pip-pkg |
pip | python+pip-pkg:numpy,pandas |
python/uv-pkg |
uv | python+uv-pkg:ruff,black |
python/conda-pkg |
conda | python+conda-pkg:scipy |
rust/cargo-pkg |
cargo | rust+cargo-pkg:ripgrep |
go/go-pkg |
go install | go+go-pkg:gopls@latest |
csharp/dotnet-pkg |
dotnet tool | csharp+dotnet-pkg:dotnet-ef |
dotnet/dotnet-pkg |
dotnet tool | dotnet+dotnet-pkg:dotnet-ef@8.0.11 |
ruby/gem-pkg |
gem | ruby+gem-pkg:rails,bundler |
haskell/cabal-pkg |
cabal | haskell+cabal-pkg:hlint |
elixir/hex-pkg |
hex | elixir+hex-pkg:phoenix |
lua/luarocks-pkg |
luarocks | lua+luarocks-pkg:luacheck |
php/pecl-pkg |
pecl | php+pecl-pkg:redis |
deno/pkg |
deno add | deno+pkg:npm:cowsay |
deno/tool |
deno install | deno+tool:npm:cowsay |
conan/conan-pkg |
Conan | conan+conan-pkg:"fmt/10.2.1" |
brew-pkg |
Homebrew | brew-pkg:htop,tmux |
apt-pkg |
apt | apt-pkg:htop,jq |
code-ext-pkg |
VS Code / code-server | code-ext-pkg:elixir-lsp.elixir-ls |
jetbrains-plugin-pkg |
JetBrains IDEs | jetbrains-plugin-pkg:IdeaVIM |
These translate to install <manager> <packages> in the Boothfile, which runs the corresponding <manager>--install.sh script during docker build.
Package names with a
/— Go module paths, scoped npm names, Conanname/versionrefs — must be quoted, or the/reads as a template separator:--select 'go+go-pkg:"github.com/user/tool@latest"'. See Quoting values that contain separators.
Editor extensions (
code-ext-pkg): bakes any VS Code / code-server extension into the image by marketplace id, for anything the curated per-language extensions don't cover. See Editor Extensions below.
IDE plugins (
jetbrains-plugin-pkg): the same idea for the JetBrains IDEs — bakes any plugin from plugins.jetbrains.com into the image. See JetBrains IDE Plugins below.
System packages (apt):
apt-pkginstalls Debian/Ubuntu packages with apt. It supports apt's nativepkg=versionpinning (apt-pkg:htop,jq=1.6-2.1) and honors theAPT_SNAPSHOTarchive freeze thatbooth configstamps for reproducible builds — the release's image snapshot on a new booth, kept as-is after that; change it with--apt-snapshot <id|today|none>(todayis a week back) or the Config tab's Apt Snapshot field. You can also addinstall apt <pkgs>directly to the Boothfile by hand. See BOOTH_CUSTOMIZATION.md and REPRODUCIBILITY.md.
Upgrading the bundled npm (
nodejs+npm-upgrade): distinct from the-pkgextensions, this opt-in extension upgrades the global npm itself to a newer version than the selected Node.js bundles —run npm install -g npm@NPM_VERSIONat build time. Use--select nodejs+npm-upgradefor the latest, ornodejs+npm-upgrade:11.18.0to pin a version. It's off by default so the npm that ships with Node.js stays the reproducible baseline.
VS Code / code-server extensions come two ways, and the curated way is the one to reach for first.
Curated, per language — <language>+vscode-ext. Each language template that has
a well-known extension ships one, auto-selected, pinning an id that is known to
resolve. Nothing to type but the language:
booth config --no-tui --variant codeserver --select "elixir" # +vscode-ext is auto-selected
booth config --no-tui --variant codeserver --select "go/rust/python" # one per languageEach compiles to setup <language>-code-extension in the Boothfile. If an editor
isn't in the image, the setup skips itself quietly, so a language selection stays
valid on a variant with no IDE.
Arbitrary, by id — code-ext-pkg. For anything not covered by a curated
extension, name the marketplace id directly:
booth config --no-tui --variant codeserver --select "code-ext-pkg:eamodio.gitlens"
booth config --no-tui --variant codeserver --select "code-ext-pkg:eamodio.gitlens,esbenp.prettier-vscode"
booth config --no-tui --variant codeserver --select "code-ext-pkg:eamodio.gitlens@15.6.0" # pinnedThis compiles to install code-extension ${CODE_EXT_PKGS} at Boothfile order 65 —
the same slot as the curated extensions, after the editor is installed. The two mix
freely: --select "elixir+vscode-ext/code-ext-pkg:eamodio.gitlens".
Three things to know about ids:
- Which registry an id resolves against depends on your variant. code-server
queries Open VSX; desktop VS Code queries the
Microsoft Marketplace. The publisher
namespaces are independent, so the same extension often has a different id on each
— ElixirLS is
elixir-lsp.elixir-lson Open VSX andJakeBecker.elixir-lson the Marketplace — and Microsoft-licensed ones (ms-dotnettools.*,ms-vscode.cpptools) are absent from Open VSX entirely. Look your id up on the registry your variant uses, per the table below. - A trailing
@versionpins the release; without one you get whatever is latest at build time. See REPRODUCIBILITY.md. - An id that fails to install fails the build, unlike the curated extensions, which warn and carry on. You named it explicitly, so a silent no-op would hand you an image missing the extension you asked for.
Both, when both are there. code-server and desktop VS Code keep separate extension trees and separate CLIs, and every install path here — curated and arbitrary alike — installs into each one it finds. Which one a booth has, and therefore which registry your ids must come from, comes from its variant:
| Variant | Editor | Registry for ids | Where extensions land |
|---|---|---|---|
codeserver |
code-server | Open VSX | /usr/local/share/code-server/extensions |
desktop-xfce / -kde / -lxqt / -wayland |
desktop VS Code | MS Marketplace | /usr/local/share/code/extensions |
base, notebook |
none | — | build stops with a message |
The curated <language>+vscode-ext extensions already handle this split for you —
elixir+vscode-ext installs elixir-lsp.elixir-ls on code-server and
JakeBecker.elixir-ls on desktop VS Code, both the real ElixirLS. code-ext-pkg
takes ids verbatim, so on a mixed image (setup codeserver and setup vscode on a
base build) an id must resolve on both registries or the build fails. Real variants
carry exactly one editor, so this only comes up if you install both by hand.
So code-ext-pkg needs an editor, not a specific one: any of the five variants
above, or setup codeserver / setup vscode in the Boothfile on a bare base
build. This is also why code-ext-pkg is a top-level template rather than an
extension hanging off codeserver — desktop variants have an editor without that
template ever being selected, and requiring it would mean installing a second,
browser-based editor just to name an extension.
On a bare
basebuild,setup vscodecurrently needssetup pythonbefore it — it installs Jupyter and a Bash kernel with pip. The desktop variants don't hit this because their desktop setup installs python first.
A JetBrains IDE auto-detects the JDK that JAVA_HOME points at, so a booth with a
single JDK already opens a Java project fine. What it does not do is find the others:
a booth can install six, and only one is on JAVA_HOME. So idea carries an
auto-selected jdk-sdk extension that registers every JDK in the image:
booth config --no-tui --variant xfce --select "java/idea" # jdk-sdk is auto-selected
booth config --no-tui --variant xfce --select "java/idea~jdk-sdk" # opt outThis compiles to setup jetbrains-jdk at Boothfile order 70, after both the JDK and
the IDE. SDKs are named <vendor>-<major> (temurin-25, corretto-17) — the name the
IDE gives a JDK it discovers itself, and the name a project's .idea/misc.xml already
refers to, so an existing project resolves without being edited.
The table is written to /etc/cb-home-seed, not into the image, because it is per-user
config and the container home is recreated per run. Seeding is no-clobber: change
the SDK list from File → Project Structure → SDKs and your version survives every
restart. Selecting an IDE with no Java, or Java with no IDE, stays valid — the setup
skips.
The JetBrains IDEs (IntelliJ IDEA, PyCharm, GoLand, ...) come with no plugins beyond
the bundled set — notably not Lombok, which IDEA Community stopped bundling.
jetbrains-plugin-pkg bakes any plugin from
plugins.jetbrains.com into the image:
booth config --no-tui --variant xfce --select "idea/jetbrains-plugin-pkg:IdeaVIM"
booth config --no-tui --variant xfce --select "idea/jetbrains-plugin-pkg:IdeaVIM,6317"
booth config --no-tui --variant xfce --select "idea/jetbrains-plugin-pkg:IdeaVIM@2.31.0" # pinnedThis compiles to install jetbrains-plugin ${JETBRAINS_PLUGIN_PKGS} at Boothfile
order 70, after the IDE is installed. Plugins land in
/opt/jetbrains-plugins/<product>/, which the IDE is pointed at with
idea.plugins.path — an image-level dir rather than the user's home, because the
container home is recreated per run.
Curated first, where one exists. idea+lombok compiles to setup lombok-idea — the
counterpart to setup lombok-eclipse — and is the way to get Lombok, rather than naming
its id. Reach for jetbrains-plugin-pkg for anything without a curated script of its own;
the two mix freely.
Three things to know about ids:
- Both id forms work. A plugin page carries an xmlId (
IdeaVIM,izhangzhihao.rainbow.brackets) and a number in its URL (6317). Use the number when the xmlId contains a space — through a Boothfile the param expands unquoted, so the shell splits on whitespace before the installer sees it, and Lombok's xmlId really is the two-word, misspelledLombook Plugin. (A curated setup calling the installer directly can pass such an id whole, which is whatlombok-ideadoes.) - A plugin need not exist for every IDE in the image. Many are IntelliJ IDEA Ultimate only, and asking for one in Community is a 404 rather than a mistake. Landing in at least one IDE is a success; landing in none fails the build.
@versionpins, and gives up dependency resolution. Unpinned ids go through the IDE's own installer, which picks the newest compatible build and pulls in the plugins this one depends on. A pinned id is fetched from the marketplace directly, which resolves nothing — name its dependencies too. See REPRODUCIBILITY.md.
With no JetBrains IDE in the image the install skips rather than failing, so the selection stays valid on a variant that has no IDE to receive it.
Plugins not published by JetBrains raise a modal on first launch. The IDE shows a "Third-Party Plugins Notice" listing them, and waits for Accept before it opens. That is the IDE's own trust prompt and it appears however the plugin was installed — it is not something baking the plugin in avoids. Plugins whose vendor is JetBrains, Lombok among them, do not trigger it. To pre-answer it, see Skipping the first-run prompts.
A fresh container means clicking through up to four JetBrains dialogs before the IDE is
usable — and since the home is recreated per run, that is every start. idea+skip-first-run
seeds the answers to three of them:
booth config --no-tui --variant xfce --select "java/idea+skip-first-run"| Prompt | What is seeded |
|---|---|
| Third-Party Plugins Notice | options/updates.xml → THIRD_PARTY_PLUGINS_ALLOWED=true |
| Trust and Open Project | options/trusted-paths.xml → the workspace path only |
| Data Sharing | consentOptions/accepted → declined |
Two deliberate limits. The User Agreement is not answered — accepting a licence on your behalf at image-build time is a legal act rather than a configuration default, so that one dialog still appears and a human still clicks it. And the extension is off by default for the same reason: these are consent decisions, so you opt in rather than having them made for you.
Trust is scoped to the workspace path, never the "trust all projects in this folder"
checkbox — the prompt exists to stop untrusted project code from executing, and the booth's
own project is the only thing its author vouched for. Pass a different path as an argument
to setup jetbrains-first-run if your project lives elsewhere.
Everything is seeded no-clobber, so changing any of it in the IDE sticks.
The older
setup jetbrains-plugin <ide> "<plugin>", which installed one plugin into one IDE at container start, has been removed;jetbrains-plugin-pkgreplaces it.
Pre-download project dependencies into the image so they're available immediately — no waiting for downloads on every container start.
# Pre-install npm dependencies from package.json
booth config --no-tui --select nodejs+npm-install
# Pre-install with pnpm (also installs pnpm globally)
booth config --no-tui --select nodejs+pnpm-install
# Pre-download Maven dependencies
booth config --no-tui --select java+maven+mvn-installCodingBooth containers bind-mount your project at runtime, so project files aren't available during docker build. These templates use Docker BuildKit's --mount=type=bind to access your project's manifest files (e.g., package.json, pom.xml) at build time, then install dependencies into a cache directory inside the image.
Globally-cached dependencies (no startup step needed):
These package managers store dependencies in a global cache that persists in the image:
| Extension | Manifest files | Cache location |
|---|---|---|
go/go-mod |
go.mod, go.sum |
$GOPATH/pkg/mod/ |
rust/cargo-build |
Cargo.toml, Cargo.lock |
~/.cargo/registry/ (select as rust+cargo-build~registry-cache) |
java/mvn-install |
pom.xml |
~/.m2/repository/ |
java/gradle-deps |
build.gradle[.kts] |
~/.gradle/caches/ |
For these, go build, cargo build, mvn compile, or gradle build can run immediately without downloading anything.
Project-local dependencies (startup copy from cache):
php+composer installs Composer globally (setup php --composer-only). The
php+composer-install row below is the next step: it runs composer install
from the project's composer.json.
These package managers install into the project directory (e.g., node_modules/, vendor/). Since the project directory is bind-mounted at runtime, the templates cache dependencies in /opt/ during build and restore them on first startup via a local filesystem copy (no network needed):
| Extension | Manifest files | Image cache | Restored to |
|---|---|---|---|
nodejs/npm-install |
package.json, package-lock.json |
/opt/npm-cache/ |
node_modules/ |
nodejs/yarn-install |
package.json, yarn.lock |
/opt/yarn-cache/ |
node_modules/ |
nodejs/pnpm-install |
package.json, pnpm-lock.yaml |
/opt/pnpm-cache/ |
node_modules/ |
bun/bun-install |
package.json, bun.lockb |
/opt/bun-cache/ |
node_modules/ |
ruby/bundle-install |
Gemfile, Gemfile.lock |
/opt/bundle-cache/ |
vendor/ |
elixir/mix-deps |
mix.exs, mix.lock |
/opt/mix-cache/ |
deps/ |
php/composer-install |
composer.json, composer.lock |
/opt/composer-cache/ |
vendor/ |
The startup copy only runs if the target directory doesn't already exist. Once node_modules/ (or equivalent) is present, the startup script is a no-op.
Existing pip template (python+pip):
Python's pip installs to system site-packages (not the project directory), so it works directly at build time with no startup step. It reads from .booth/requirements.txt:
booth config --no-tui --select python+pip
# Then create .booth/requirements.txt with your dependenciesDependencies are baked into the image. When you change your manifest files (add/remove packages), rebuild the image:
booth # Booth auto-rebuilds when the Boothfile or manifest files changeBetween rebuilds, you can still run npm install, pip install, etc. manually inside the container — those changes apply immediately but won't survive container recreation.
Some language libraries need a separate tool binary that the package install
does not pull in. Selecting python+pip-pkg:grpcio installs the Python package;
it does not install protoc. Same pattern for browser automation, media
tooling, and ORM CLIs.
Use this catalog when a dependency works on your laptop but the booth is missing
a CLI or engine. Prefer a dedicated template when one exists (discoverable in
the TUI, pin-friendly, requires edges). Fall back to a *-pkg selection when
the companion is a normal package-manager install.
Full audit and implementation notes: TODO-BINARY_COMPANIONS.md.
| You need | Select | What you get |
|---|---|---|
| protoc (Protocol Buffers compiler) | protobuf |
apt protobuf-compiler |
| Go protoc plugins | protobuf+go (or go/protobuf+go) |
protoc-gen-go + protoc-gen-go-grpc (auto-pulls go) |
| Buf CLI | buf or buf:1.72.0 |
official release binary via setup buf |
| ffmpeg (standalone) | ffmpeg |
apt ffmpeg (Remotion/VHS still install their own) |
Graphviz / dot |
graphviz |
apt graphviz (PlantUML still installs its own) |
| Playwright browsers | playwright |
browsers under /opt/ms-playwright |
| Puppeteer Chromium | puppeteer |
shared PUPPETEER_CACHE_DIR=/opt/puppeteer |
| Cypress binary | cypress |
shared CYPRESS_CACHE_FOLDER=/opt/cypress |
| Selenium drivers | selenium or selenium:all |
Chrome for Testing + chromedriver (+ geckodriver for all) |
.NET global tools (e.g. dotnet ef) |
csharp+dotnet-pkg:dotnet-ef |
dotnet tool install --global |
| Mermaid CLI | mermaid |
dedicated template (also via npm) |
| PlantUML | plantuml |
CLI + server; pulls Graphviz |
| Remotion / VHS | remotion / vhs |
product setups (include ffmpeg as needed) |
# gRPC / protobuf with Go plugins + modern Buf workflow
booth config --no-tui --select go/protobuf+go/buf
# Entity Framework CLI on C#
booth config --no-tui --select csharp+dotnet-pkg:dotnet-ef
# Headless browser stacks
booth config --no-tui --select puppeteer
booth config --no-tui --select cypress
booth config --no-tui --select seleniumWhen there is no dedicated template, or you prefer a one-line package install:
| Library / ecosystem package | Also select | Notes |
|---|---|---|
grpc / grpcio / @grpc/grpc-js / google.golang.org/grpc |
protobuf or apt-pkg:protobuf-compiler + language plugins |
Prefer protobuf + protobuf+go for Go |
| Python gRPC plugins only | python+pip-pkg:grpcio-tools |
Still needs protoc itself |
| Connect-RPC / Buf workflows | buf or go+go-pkg:"…/buf@latest" / nodejs+npm-pkg:"@bufbuild/buf" |
Prefer buf template |
| Apache Thrift | apt-pkg:thrift-compiler |
|
| FlatBuffers | apt-pkg:flatbuffers-compiler |
|
| Cap’n Proto | apt-pkg:capnproto |
|
| OpenAPI generators | nodejs+npm-pkg:"@openapitools/openapi-generator-cli" or go-pkg oapi-codegen |
|
| GraphQL codegen | nodejs+npm-pkg:"@graphql-codegen/cli" |
|
| grpcurl | go+go-pkg:"github.com/fullstorydev/grpcurl/cmd/grpcurl@latest" |
|
| ANTLR runtimes | apt-pkg:antlr4 |
apt may lag upstream |
| JavaCC | apt-pkg:javacc |
|
| Bison / Flex | apt-pkg:bison,flex |
|
| tree-sitter CLI | rust+cargo-pkg:tree-sitter-cli |
|
| moviepy / pydub / similar | ffmpeg or apt-pkg:ffmpeg |
Prefer ffmpeg template |
| Graphviz language bindings | graphviz or apt-pkg:graphviz |
Prefer graphviz template |
| pandoc filters | apt-pkg:pandoc |
|
| Prisma | nodejs+npm-pkg:prisma |
runtime @prisma/client is a project dep |
| diesel / sqlx CLIs | rust+cargo-pkg:diesel_cli / sqlx-cli |
diesel may need cargo features |
| Entity Framework | csharp+dotnet-pkg:dotnet-ef |
pin with dotnet-ef@8.0.11 |
| wasm-pack | rust+cargo-pkg:wasm-pack |
add the target too: rust+targets:wasm32-unknown-unknown |
| Puppeteer (project dep only) | still select puppeteer for browsers |
npm package alone is not enough |
| Cypress (project dep only) | still select cypress for the binary |
same |
| Selenium language bindings | selenium + python+pip-pkg:selenium or nodejs+npm-pkg:selenium-webdriver |
drivers ≠ bindings |
# Older style: pure *-pkg, no dedicated protobuf/buf templates
booth config --no-tui \
--select 'go+go-pkg:"google.golang.org/protobuf/cmd/protoc-gen-go@latest","google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"' \
--select apt-pkg:protobuf-compiler
# Media libs that shell out to ffmpeg
booth config --no-tui --select python+pip-pkg:moviepy/ffmpeg- Codegen (
.proto, thrift, grammars) → need a CLI onPATH. - Embedded engines (browsers, ffmpeg, graphviz) → need a system or cached binary.
- ORM / schema CLIs (Prisma,
diesel_cli,dotnet ef) → separate from the runtime package.
It does not apply when the package only needs lib*-dev (C/C++ track) or already
vendors its binary inside node_modules / a wheel after install.
C/C++ lib-* / *-dev packages and full toolchains (GraalVM, Android SDK, CUDA) are
out of scope here — see TODO-BINARY_COMPANIONS.md.