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 is bothy. bothy creates it as a superuser when the service starts.
  • database - the database that the URL names. The default is postgres. bothy creates it when the service starts, with user as the owner.
  • extensions - extensions to build into postgres, for example pgvector. Each name is a package in postgresql16Packages in 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 exports BOTHY_<NAME>_URL. Most applications read DATABASE_URL and 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} for minio. The built-in types have a url. A command service 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 a command service 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 is true. See Services that do not start on cd.
  • env - more environment variables for this service and its setup. A value can read a bothy variable, a secret of this service or a value of the top-level env with $NAME or ${NAME}, for example postgres://app:${PGPASS}@localhost:${BOTHY_DB_PORT}/app. Write $$ for one $. If a name is not set, the service does not start. bothy.yaml is 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 and SIGKILL on bothy 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 through sh, so $PORT expands.
  • 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 own PATH. Your shell gets it too, behind everything that tools declares.

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_range applies to a service with one port: a command service with port_env, or a built-in type with one port. A type with named ports, such as mailpit, 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 down in 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_range while 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.