Configuration
Every field of bothy.yaml, from the common ones to the rare ones.
bothy.yaml is one file at the root of your worktree. You commit it. Every field is
optional, so an empty file is valid.
The file at a glance
# Programs to put on PATH inside this worktree.
tools:
- nodejs_22
- ripgrep
# Processes that bothy starts, gives ports to, and stops.
services:
db:
type: postgres # a built-in type
url_env: DATABASE_URL # export its URL under this name too
web:
type: command # any program
command: "npm run dev -- --port $PORT"
setup: "npm ci" # runs before each start
port_env: PORT # put the allocated port in $PORT
depends_on: [db] # start after db is ready
# Where passwords and keys come from. This is the default.
secrets:
source: .env
| field | what it holds | most projects need it |
|---|---|---|
tools |
Packages to put on PATH. |
yes |
services |
Databases, caches, dev servers and workers. | yes |
secrets |
The file with passwords and keys, and who gets them. | sometimes |
env |
Variables for your shell and every service. | sometimes |
nixpkgs |
The package set revision. bothy pins one for you. | no |
allow_unfree |
Permission for packages with an unfree license. | no |
allow_insecure |
Permission for packages with known vulnerabilities. | no |
bothy explain reads the file and reports every error, with the line. bothy schema
prints a JSON Schema, so your editor can complete the fields. See
Commands.
The sections below explain each field. Fields that most projects use come first.
services
Long-running processes that bothy starts, health-checks, gives ports to, and stops. Each service has a name that you choose:
services:
db:
type: postgres
url_env: DATABASE_URL
web:
type: command
command: "npm run dev -- --port $PORT"
port_env: PORT
depends_on: [db]
Service types
A service runs any binary, so there is no fixed list of what bothy supports. type: command names the binary yourself and gets the same ports, health checks, logs and
supervision as everything else. See
A service with no built-in type for a full example.
The built-in types are shortcuts for common servers. Each one is a declaration that most projects copy by hand:
| type | package | notes |
|---|---|---|
postgres |
postgresql_{version} |
version 14 to 17, default 16; user, database, extensions |
mysql |
mysql{version} |
version 84, the default |
mariadb |
mariadb |
|
redis |
redis |
|
valkey |
valkey |
redis-compatible |
memcached |
memcached |
no state to keep |
meilisearch |
meilisearch |
|
typesense |
typesense |
|
qdrant |
qdrant |
two ports: http, grpc |
nats |
nats-server |
JetStream on, stored per worktree |
beanstalkd |
beanstalkd |
|
mosquitto |
mosquitto |
MQTT |
mailpit |
mailpit |
two ports: smtp, http |
mailhog |
mailhog |
two ports: smtp, http |
minio |
versitygw |
S3 API, two ports: api, console; keys bothyadmin |
etcd |
etcd |
two ports: client, peer |
A built-in type brings its own package, the start command, the data directory, the
readiness check and the URL. It also puts its package on your PATH: type: postgres
gives you psql, pg_dump and createdb. Declared tools still come first.
Fields for a postgres service
user- the role that the URL connects as. The default isbothy. bothy creates it as a superuser when the service starts.database- the database that the URL names. The default ispostgres. bothy creates it when the service starts, withuseras the owner.extensions- extensions to build into postgres, for examplepgvector. Each name is a package inpostgresql16Packagesin nixpkgs, or the set for your version.
services:
db:
type: postgres
user: app
database: app_dev
extensions: [pgvector]
This gives BOTHY_DB_URL=postgres://app@127.0.0.1:<port>/app_dev.
bothy does not run CREATE EXTENSION. Your migrations do that, as they do in
production. Modules that ship with postgres, such as pgcrypto, need no entry. bothy
preloads the extensions that need it, such as pg_stat_statements and timescaledb.
Fields for every service
depends_on- services that must be healthy before this one starts.url_env- a second variable name for the URL of this service, or a list of names. bothy always exportsBOTHY_<NAME>_URL. Most applications readDATABASE_URLand nothing else, so ask for that name here.url- how to reach this service.{port}becomes the allocated port. For a type with more than one port,{port.<name>}becomes the port of that name, for example{port.console}forminio. The built-in types have aurl. Acommandservice needs this field to get a URL at all.health_check- a command whose exit status decides when the service is ready. bothy polls it, so keep it cheap. Without it, bothy uses the check of the type. For acommandservice that check is a connection to the port. This is right for a dev server and wrong for most databases, which accept a connection before they can serve one.autostart- whether entry into the worktree starts this service. The default istrue. See Services that do not start on cd.env- more environment variables for this service and itssetup. A value can read a bothy variable, a secret of this service or a value of the top-levelenvwith$NAMEor${NAME}, for examplepostgres://app:${PGPASS}@localhost:${BOTHY_DB_PORT}/app. Write$$for one$. If a name is not set, the service does not start.bothy.yamlis committed, so this is for configuration. For a password or an API key, use secrets.secrets- the secrets that this service gets, and the name it wants each one under. See Secrets.stop_timeout_secs- the time between the stop signal andSIGKILLonbothy down. The default is 5 seconds. Give a database more, for example 30.port_range- the only ports this service can get, written as the first and the last port, for example[3000, 3009]. For one port, write[3000, 3000]. See Ports a service must stay inside.
Fields for a command service
command- the command to run. It goes throughsh, so$PORTexpands.setup- a command that runs in the worktree before each start, or a list of commands that run in order. Keep each one cheap and safe to run twice, because bothy keeps no “already done” marker. A non-zero exit stops the list and fails the start.port_env- the variable that bothy puts the allocated port in, or a list of variables that all get the same port. Omit it for a service that does not listen.runtime- one more nixpkgs package for this service. The service gets it first on its ownPATH. Your shell gets it too, behind everything thattoolsdeclares.
setup, port_env and url_env take one value or a list. An empty list is an error.
For a list of setup steps, a failure gives the number and the text of the step:
services:
web:
type: command
command: "pnpm dev"
setup:
- "pnpm install"
- "pnpm prisma generate"
- "pnpm prisma migrate deploy"
port_env: [PORT, E2E_PORT]
setup step 3 of 3 for service 'web' failed (exit status: 1): `pnpm prisma migrate deploy`; check its log with `bothy logs web`
port_env names the variable, but your dev server must still use it. Rails and many
Node servers read $PORT on their own. Astro and Vite do not, and take their default
port instead. When you are not sure, pass the port to the command:
command: "npm run dev -- --port $PORT"
A service with no built-in type
A command service can do everything a built-in type does:
tools:
- path: /opt/mydb/bin
services:
mydb:
type: command
command: "mydb serve --port $PORT --data $BOTHY_MYDB_DATA_DIR"
setup: "mydb migrate --data $BOTHY_MYDB_DATA_DIR"
health_check: "mydb-cli --port $PORT ping"
port_env: PORT
stop_timeout_secs: 30
url: "mydb://127.0.0.1:{port}"
url_env: DATABASE_URL
Services that do not start on cd
A cd into a worktree to read one file must not build a bundle. Set autostart: false on a service that must wait for you:
services:
db:
type: postgres
web:
type: command
command: "npm run dev -- --port $PORT"
port_env: PORT
autostart: false
Entry into that worktree starts postgres and leaves web alone. bothy still reserves
the port of web and still exports its variables, so your shell gets $PORT whether
web runs or not. bothy status shows web as stopped.
bothy up web # start it, and anything in its depends_on that is down
bothy down web # stop it, and keep the database that it speaks to running
bothy exec --with web -- npm test # start it and run one command
A service that starts on entry cannot depend on one that does not. bothy rejects that file.
A bothy down on a service that does start on entry is a pause, not a stop. The next
entry starts it again. Only autostart: false keeps a service down until you ask.
Ports a service must stay inside
An OAuth provider accepts a redirect_uri only on the ports that you registered with
it. A port from the usual range of bothy does not match, and the provider refuses the
callback. port_range tells bothy which ports the service can use:
services:
web:
type: command
command: "npm run dev -- --port $PORT"
port_env: PORT
url: "http://localhost:{port}"
port_range: [3000, 3009]
Register every port in the range with the provider. Each worktree gets a different port
from the range, so ten ports let ten worktrees run web at the same time.
port_rangeapplies to a service with one port: acommandservice withport_env, or a built-in type with one port. A type with named ports, such asmailpit, refuses it.- A worktree keeps its port while a shell is in it or the service runs. After that, another worktree can take the port when the range has no free one. The first worktree gets a new port from the range at its next entry.
- If every port in the range is in use, the service does not start. bothy never moves it
outside the range, because that breaks the callback with no error. The error names the
worktrees that hold the range. Run
bothy downin one of them. - If a program that bothy did not start listens on the port, bothy moves the service to another free port in the range.
- If you add or change
port_rangewhile the service runs, bothy restarts it on a port inside the new range.
tools
A list of packages put on your PATH. bothy does not supervise them. Each entry is a
nixpkgs package name, or a directory for a binary that is not in nixpkgs:
tools:
- ripgrep # a nixpkgs package, pinned
- path: /opt/mydb/bin # a directory on this machine, used as it is
Any attribute in the pinned nixpkgs works. Dotted names like python3Packages.pip
work too. tools means binaries on PATH, so a library-only package such
as zlib is rejected. For a library, put the package in the runtime of a command
service.
bothy puts its own directories first on PATH. Inside a worktree, tools: [nodejs_22]
hides a node from brew, asdf or mise. Outside the worktree, your usual node is back.
Two packages that hold a binary of the same name are a warning, not an error. The
tools: entry wins, because tools comes first. A service always runs its own
package, whatever your shell gets.
bothy tools add <attr> and bothy tools remove <attr> edit this list for you, and
add checks the attribute against the pinned nixpkgs. Use bothy tools add path:/opt/mydb/bin for a directory.
env
The top-level env gives variables to your shell and to every service. Use it for a
value that a command you run by hand also needs, such as the URL a migration tool reads.
env:
DIRECT_URL: "postgres://app:${PGPASS}@localhost:${BOTHY_DB_PORT}/app"
A value expands like the env of a service, but it can read only the secrets that every
service gets. The env of a service can read a top-level value, and it wins over one of
the same name. A top-level name cannot be a BOTHY_* name, PATH, a name that
port_env or url_env gives, or a variable that a built-in type sets, such as
ROOT_ACCESS_KEY for minio.
Environment variables
<NAME> is the service name from bothy.yaml, in uppercase. A service named db
gives BOTHY_DB_PORT. A service name must match [A-Za-z_][A-Za-z0-9_]*, so my-db
is rejected.
Set in your shell and in every service:
| variable | when | value |
|---|---|---|
BOTHY_<NAME>_DATA_DIR |
always | the per-worktree directory of that service |
BOTHY_<NAME>_PORT |
the service has a port | the allocated port |
BOTHY_<NAME>_URL |
a built-in type, or a service with url |
the connection URL |
what port_env names |
port_env is set |
the port of that service |
what url_env names |
url_env is set |
the URL of that service |
what the top-level env names |
always | see env |
Set for one service only, never in your shell:
| variable | from |
|---|---|
what env names |
the env map of that service |
what secrets names |
the secrets map of that service |
bothy never invents a variable name. When an application needs a particular name, ask
for it with url_env.
bothy creates each BOTHY_<NAME>_DATA_DIR before setup runs and keeps it across
restarts. This is what makes two worktrees of one repository independent.
A type with more than one port gets all of them, exported by name:
BOTHY_MAIL_PORT # the primary port, what an application connects to
BOTHY_MAIL_SMTP_PORT
BOTHY_MAIL_HTTP_PORT
BOTHY_MAIL_URL # http://127.0.0.1:<http port>, what a person opens
The shell hook also sets these variables. bothy exec sets only PATH and
BOTHY_TOOL_DIRS of them.
| variable | value |
|---|---|
PATH |
your PATH, with the tool directories of the worktree first |
BOTHY_TOOL_DIRS |
the tool directories that bothy put on PATH, separated by : |
BOTHY_ROOT |
the root of the worktree |
BOTHY_SESSION_ID |
the session of this shell |
BOTHY_VARS |
the names of the variables that bothy exported |
BOTHY_ENV_SOURCE |
the path of the secret source, such as .env |
BOTHY_PENDING |
1 while bothy still sets the worktree up in the background |
When you leave the worktree, the hook uses BOTHY_TOOL_DIRS and BOTHY_VARS to take
back what it added. bothy explain leaves BOTHY_TOOL_DIRS out of its list, but the
hook sets it.
What a service inherits
A service gets what bothy.yaml and the secret source give it, and almost nothing
else. bothy clears the environment before it starts a service, its setup and its
health_check. These variables pass through from the machine, because programs break
without them:
HOME USER LOGNAME TMPDIR LANG LC_* TZ
LOCALE_ARCHIVE TZDIR
SSL_CERT_FILE SSL_CERT_DIR NIX_SSL_CERT_FILE CURL_CA_BUNDLE
If a service needs another variable, put it in env, or in the secret source for a
value that you do not commit.
Editing bothy.yaml
Changes take effect at your next shell prompt, in the shell you are already in:
- A service that you added starts, and its variables are exported.
- A service that you removed stops.
- A tool that you added is resolved and put on
PATH.
Slow work does not hold your prompt. If a change needs a download, a setup or a
health check, the cd waits at most 100 milliseconds. Then bothy gives the prompt back
and continues in the background. bothy status shows the progress. The first prompt
after the work finishes exports the new environment and tells you. bothy exec and
bothy up wait for the work to finish.
If a change breaks entry, bothy reports the error and exports nothing. The worktree is then not active. Correct the file, and the next prompt tries again.
A tool that does not resolve is a warning, not the end of the worktree. The services
still start, and bothy repeats the warning on every entry. bothy explain --resolve is
the strict form, and fails on the same input.
nixpkgs
The nixpkgs revision that every package in this worktree resolves against. Omit it and bothy uses its own pinned default. Pin a revision, not a branch:
nixpkgs: github:NixOS/nixpkgs/ac62194c3917d5f474c1a844b6fd6da2db95077d
bothy resolves the packages of a bothy.yaml one time. All worktrees that declare the
same packages share that result, so a new worktree of the same repository enters with
no call to Nix. bothy asks Nix again only when you change tools, nixpkgs,
allow_unfree, or the package or runtime of a service. A nixpkgs that names a branch
therefore does not move until you run bothy explain --resolve.
allow_unfree
Whether bothy can build a package that has an unfree license, such as ngrok or
terraform. The default is true, so such a package is one more line in tools.
allow_unfree: false
Set it to false for a project that must build nothing proprietary. bothy then refuses
the package and names it and this field in one line.
A committed bothy.yaml resolves to the same package on your machine and in CI. Your
own ~/.config/nixpkgs settings do not reach the build.
allow_insecure
Whether bothy can build a package that nixpkgs marks insecure. nixpkgs does this when a
package has known vulnerabilities, for example a release that upstream abandoned with
open CVEs. The default is false.
allow_insecure: true
Without it, bothy refuses the package and prints one line. That line names the package
that nixpkgs refused, which can be a dependency of the one you declared, and this field.
Only bothy.yaml decides. NIXPKGS_ALLOW_INSECURE and your own config.nix do not
reach the build. bothy always refuses a package that nixpkgs marks broken.
The minio type runs versitygw, an S3 server that nixpkgs does not mark insecure. The
nixpkgs that bothy pins marks minio itself insecure.
When a service crashes
bothy says so, and does not start the service again. bothy status shows it:
$ bothy status --print
~/src/app a1b2c3d4 1 session (pid 4242)
db postgres healthy port 28001 pid 50360
ingest command crashed
exited (code 137) 12m ago
The code is the shell one. 137 is a SIGKILL, 143 a SIGTERM, and 1 an ordinary
failure. bothy status --json gives the same under last_exit. The log of the service
gets one line, [bothy] crashed (code 137). That is the only line that bothy writes
into a log. bothy up <service> starts the service again, and so does the next entry
for a service that autostart covers.
bothy reports only an exit that it did not ask for:
- A
bothy down, and a teardown after the grace period, report nothing. - A process that exits with code 0 shows as an exit, not a crash.
- A service that runs but fails its health check is not a crash. It shows
unhealthy.
Lifecycle and cleanup
Entry registers a session against the worktree, tied to the PID of your shell. bothy status prints those pids, because a worktree that never stops is usually one forgotten
terminal tab. The hook ends the session when you cd out or the shell exits. The daemon
also watches that PID, so it notices a shell that is killed. When the last session of a
worktree ends, a grace period of 60 seconds starts. Then bothy stops the services.
A service has one of five states in bothy status:
| state | meaning |
|---|---|
healthy |
It runs and passes its health check. |
running |
It runs, and bothy has no health result yet. |
unhealthy |
It runs, but fails its health check. |
crashed |
It exited, and bothy did not ask it to. |
stopped |
It does not run, because bothy stopped it or never started it. |
A stop is not a delete. Data directories stay across sessions. Ports stay reserved for
the worktree, so a new entry gets the same ones. A port from a port_range is the one
exception. See Ports a service must stay inside. A
teardown stops every service of the
worktree, autostart: false services included. At the next entry, the databases start,
and a manual service waits until you ask for it again.
Deleted and moved worktrees
When you delete or move a worktree, the daemon forgets it after the grace period. It stops what still ran there first. You do not need a command for this.
The daemon keeps the data of a forgotten worktree for seven days, and then deletes it.
If you moved the worktree, the first entry at the new path takes the data back, because
.bothy/id moved with it. bothy status --all lists the data that waits:
forgotten worktrees, data kept:
~/src/myproject-wt3 0969fe1f6f5918e5 data deleted in 6d
To change the seven days, set BOTHY_PURGE_AFTER_SECS in the environment of the daemon.
Set it to 0 to keep the data until you delete it yourself.
bothy clean
Services outlive the daemon by design. If you kill the daemon, the services keep
running with nothing to track them. bothy clean stops those, and forgets worktrees
that no longer exist without a wait:
$ bothy clean --dry-run
would forget 2 stale worktree(s):
0969fe1f6f5918e5 /home/you/src/myproject-wt3 (directory is gone)
5728f3162fa9a641 /home/you/src/myproject-wt2 (recreated with a new id)
their data is kept for 7d and then deleted; `bothy clean --purge` deletes it now
bothy clean only touches processes that bothy started. A port that your own postgres
holds stays as it is.
Run bothy clean --dry-run --purge before bothy clean --purge. The purge deletes the
data directories of forgotten worktrees at once, and you cannot get them back.
How bothy finds the worktree
bothy walks up from the current directory to the first bothy.yaml, the way git looks
for .git. Put worktrees beside each other, not inside each other. A directory nested
in a worktree with no bothy.yaml of its own gets the ports and services of the
parent.
Ports come from the range 28000 to 28999. A service with a port_range gets its port
from that range instead.
State layout
Everything bothy owns lives under $XDG_STATE_HOME/bothy, which is
~/.local/state/bothy by default:
bothyd.sock, bothyd.pid, bothyd.log, bothyd.version
registry.json # worktrees, ports, sessions
toolchains.json # resolved toolchains, shared by worktrees
w/<id>/<service>/ # the data directory of the service
w/<id>/.logs/<service>.log
The only bothy files in your repository are bothy.yaml, which you commit, and
.bothy/, which is gitignored. .bothy/id gives the worktree a stable identity, so a
move or a rename does not lose its ports and data.