CI
Run the tools and services from bothy.yaml in GitHub Actions and other CI systems.
The bothy.yaml that you use on your machine also works in CI. The CI job gets the same
tool versions and the same services, from the same file. You do not write a second
description of your stack in the workflow file.
Why use bothy in CI
- One file describes the stack. If you add redis to
bothy.yaml, CI gets redis in the next run. You do not edit aservices:block in the workflow. - CI uses the same versions as your machine. bothy resolves each package from the
nixpkgs revision that bothy pins, or the one that
bothy.yamlnames. The Nix settings of a machine do not change the result. - The services are plain processes. They run on macOS runners too, where GitHub Actions has no service containers.
- Jobs on one self-hosted runner do not collide. Each checkout gets its own ports and its own data, as each worktree does on your machine.
How CI is different
On your machine, the shell hook starts the services when you cd into the worktree. A
CI job has no interactive shell and no terminal, so you use three commands instead:
-
Install the
bothybinary in~/.local/bin:curl -fsSL https://usebothy.dev/install.sh | bash -
Install Nix without a prompt. If Nix is already there, this does nothing:
bothy install nix --yes -
Start the services, then run your command with the environment:
bothy exec -- npm test
You do not need the shell hook in CI. Do not run bothy install without nix, because
that also edits the rc file of the shell.
--yes is necessary. Without a terminal, bothy cannot ask before it installs Nix, so it
stops with this error:
no terminal to confirm on. `bothy install nix --yes` installs without one.
The Nix installer uses sudo. GitHub-hosted runners have sudo without a password.
What bothy exec does
bothy exec -- npm test does these steps in order:
- It starts the bothy daemon, if it does not run.
- It resolves the tools and the service packages. On a new runner, Nix downloads them.
bothy execwaits for this. - It starts each service that has
autostartset, independs_onorder. It runs eachsetupfirst, and waits until each service passes its health check. - It runs
npm testwith the tools onPATHand the bothy variables set, such asDATABASE_URL. - It exits with the exit code of
npm test.
If a service fails to start, bothy exec prints the error and exits with a non-zero
code. Your command does not run. If bothy cannot find your command, the exit code is
127.
Your command also gets the environment of the CI step, such as CI and
GITHUB_SHA. The services do not. bothy clears the environment of each service. See
What a service inherits.
When your command exits, bothy waits 60 seconds and then stops the services. The data directories stay until the runner goes away.
GitHub Actions
This workflow runs the tests of a Node project against postgres and redis. The
bothy.yaml is the one from Getting started:
# .github/workflows/test.yml
name: test
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
# bothy downloads nixpkgs from GitHub. A token avoids the limit on anonymous requests.
NIX_CONFIG: access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v5
- name: install bothy
run: |
curl -fsSL https://usebothy.dev/install.sh | bash
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: install nix
run: bothy install nix --yes
- name: test
run: bothy exec -- npm test
The first run on a new runner takes longer, because Nix downloads every package. The
job then spends most of its time in the test step.
Set NIX_CONFIG for the job, not for one step. bothy passes it to every nix command
that it runs, and bothy does not edit nix.conf.
Run more than one command
Each run: step in GitHub Actions is a new shell. Each bothy exec is a new session in
the worktree. Thus there are two ways to run more than one command.
To keep the commands together, run a shell inside one bothy exec:
- name: migrate and test
run: |
bothy exec -- sh -c '
npm ci
npm run migrate
npm test
'
To keep each command in its own step, put bothy exec in each step:
- run: bothy exec -- npm ci
- run: bothy exec -- npm run migrate
- run: bothy exec -- npm test
A second bothy exec in the same job gets the same ports and the same data. If the
services still run, it uses them. If they stopped, it starts them again, and postgres
keeps the tables from the earlier step. Each start runs setup again.
Use the tools in later steps
Sometimes a step needs only the tools, and no services. For example, a lint step needs
node, but not postgres. Add the tool directories to $GITHUB_PATH one time:
- name: put the bothy tools on PATH
run: |
dirs="$(bothy exec -- sh -c 'printf "%s" "${BOTHY_TOOL_DIRS:-}"')"
if [ -z "$dirs" ]; then
echo "bothy exec resolved no tool directories" >&2
exit 1
fi
printf '%s\n' "$dirs" | tr ':' '\n' >> "$GITHUB_PATH"
- run: node --version # the node from bothy.yaml
- run: npm run lint
BOTHY_TOOL_DIRS holds the directories that bothy put on PATH. They are in the Nix
store, so they stay valid after the session ends. bothy builds itself in CI this way.
Linux and macOS
bothy has releases for ubuntu-latest and macos-latest. One matrix runs the same
bothy.yaml on both:
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
NIX_CONFIG: access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v5
- run: |
curl -fsSL https://usebothy.dev/install.sh | bash
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- run: bothy install nix --yes
- run: bothy exec -- npm test
A GitHub service container runs on Linux runners only. A bothy service runs on both.
A Python project
This is a Django project with a Celery worker. The bothy.yaml declares python, uv,
postgres and redis:
# bothy.yaml
tools:
- python312
- uv
services:
db:
type: postgres
url_env: DATABASE_URL
cache:
type: redis
url_env: CELERY_BROKER_URL
The test step is one line:
- name: test
run: bothy exec -- uv run pytest
uv run installs the packages from uv.lock before it runs pytest. You do not need a
separate setup step for python.
Services that do not start on entry
A service with autostart: false stays down in bothy exec too. Use --with to start
it for one command:
services:
db:
type: postgres
url_env: DATABASE_URL
web:
type: command
command: "npm run start -- --port $PORT"
setup: "npm ci && npm run build"
port_env: PORT
url: "http://127.0.0.1:{port}"
url_env: APP_URL
depends_on: [db]
autostart: false
- name: unit tests
run: bothy exec -- npm test
- name: end-to-end tests
run: bothy exec --with web -- npx playwright test
The unit tests get postgres and no web server. The end-to-end tests get both, and
Playwright finds the server at $APP_URL. Name --with one time for each service.
Secrets in CI
A CI checkout has no .env, because git ignores it. That is not an error. bothy starts
the services with no secrets.
Your command gets the variables of the CI step. Thus a test that reads STRIPE_KEY
gets it from the step:
- name: test
env:
STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
run: bothy exec -- npm test
A service does not get the variables of the step. If a service needs a secret, write a
.env before bothy exec:
- name: write the secret source
env:
STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
run: printf 'STRIPE_KEY=%s\n' "$STRIPE_KEY" > .env
- name: test
run: bothy exec -- npm test
If a service lists a secret in secrets: and the file does not set it, bothy prints a
warning and starts the service. See Secrets.
Show the logs when a step fails
bothy writes the output of each service to a log file. The files stay after
bothy exec exits. Add a step that runs only on failure:
- name: service logs
if: failure()
run: |
bothy logs db -n 100
bothy logs web -n 100
To keep the complete files, upload the log directory:
- name: upload service logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: bothy-logs
path: ~/.local/state/bothy/w/*/.logs/
Check bothy.yaml in CI
bothy explain reads bothy.yaml and reports every error in it. It needs no Nix and no
daemon, so it takes less than a second. Run it in a fast job, to find a broken file
before a long job starts:
check-bothy-yaml:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: curl -fsSL https://usebothy.dev/install.sh | bash
- run: ~/.local/bin/bothy explain
bothy explain --resolve also makes sure that every package resolves in nixpkgs. It
needs Nix, so run bothy install nix --yes before it.
Other CI systems
bothy does not depend on GitHub Actions. The same three commands work in a shell script that any CI system can run:
#!/bin/sh
set -eu
curl -fsSL https://usebothy.dev/install.sh | bash
export PATH="$HOME/.local/bin:$PATH"
bothy install nix --yes
bothy exec -- npm test
The runner must have these things:
- Linux on x86_64, or macOS on Apple silicon.
curl, andsudothat does not ask for a password.- Network access to the bothy releases, to the Nix installer, and to the Nix binary cache.
bothy itself runs in CI on the GitHub-hosted ubuntu-latest and macos-latest
runners. Other runners are not tested. bothy is not tested inside a container job.
Cache between runs
bothy does not cache anything between CI runs. On a GitHub-hosted runner, every run is a new machine. Without a cache, every run downloads the same packages again. You add the cache with the tools of your CI system.
A run spends its time on three kinds of downloads:
| what | where it goes | cache it with |
|---|---|---|
| The tools and service packages | the Nix store, /nix |
nix-community/cache-nix-action |
| The nixpkgs source that bothy resolves against | ~/.cache/nix |
the same action, as an extra path |
| The dependencies of your application | for example ~/.npm or the uv cache |
actions/cache |
Do not cache ~/.local/state/bothy. It holds the state of the daemon: ports, sessions
and the data directories of the services. A copy from another runner does not match the
new runner, and a CI run must start with empty databases.
Cache the Nix store
nix-community/cache-nix-action saves /nix at the end of the job and restores it at
the start of the next one. It needs Nix on the runner first, so put it after
bothy install nix --yes. The action supports Nix from the Determinate Systems
installer, which is the installer that bothy runs.
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
# The cache action needs this to delete old caches. See `purge` below.
permissions:
contents: read
actions: write
env:
NIX_CONFIG: access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v5
- name: install bothy
run: |
curl -fsSL https://usebothy.dev/install.sh | bash
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: install nix
run: bothy install nix --yes
- name: read the bothy version
id: bothy
run: echo "version=$(bothy --version | tr ' ' '-')" >> "$GITHUB_OUTPUT"
- name: restore the nix store
uses: nix-community/cache-nix-action@v7
with:
primary-key: bothy-${{ runner.os }}-${{ steps.bothy.outputs.version }}-${{ hashFiles('bothy.yaml') }}
restore-prefixes-first-match: bothy-${{ runner.os }}-
paths: ~/.cache/nix
gc-max-store-size: 4G
purge: true
purge-prefixes: bothy-${{ runner.os }}-
purge-created: 0
purge-primary-key: never
- name: test
run: bothy exec -- npm test
The key holds three parts:
runner.os, because a Linux store does not work on macOS.- The bothy version, because each bothy release can pin a new nixpkgs, and a new nixpkgs gives new packages.
- A hash of
bothy.yaml, because the file decides which packages you need.
If the key is new, the action restores the most recent cache that starts with
bothy-<os>-. Most of the packages are then already in the store, and Nix downloads
only the packages that changed. At the end of the job, the action saves the store under
the new key, and purge deletes the old caches of this branch.
gc-max-store-size runs nix store gc before the save, until the store is at most that
size. bothy keeps a GC root for every package that a worktree uses, so the collection
keeps the packages of this bothy.yaml. Set the size above the size of your store, or
the cache stays incomplete.
Unfree packages
The public Nix binary cache, cache.nixos.org, does not hold packages with an unfree
license. If tools names such a package, for example terraform, Nix builds it on the
runner in every run. A cache of the Nix store saves the most time for such a project.
Cache the dependencies of your application
The setup-node and setup-python actions have a cache option, but with bothy you do
not use those actions, because the tools come from bothy.yaml. Use actions/cache
directly, and key it on your lockfile.
For npm:
- name: restore the npm cache
uses: actions/cache@v6
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
- name: test
run: bothy exec -- sh -c 'npm ci && npm test'
For pnpm, ask pnpm where its store is, because the path is different on Linux and macOS:
- name: find the pnpm store
id: pnpm
run: echo "store=$(bothy exec -- pnpm store path)" >> "$GITHUB_OUTPUT"
- name: restore the pnpm store
uses: actions/cache@v6
with:
path: ${{ steps.pnpm.outputs.store }}
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
For uv, do the same with bothy exec -- uv cache dir, and key the cache on uv.lock.
Put each restore step before the step that installs the dependencies. If setup in
bothy.yaml installs them, put the restore step before the first bothy exec.
Other ways to cache
- The Magic Nix Cache action from Determinate Systems stores Nix packages in the GitHub Actions cache, and works as a binary cache for Nix. Determinate Systems stopped it in February 2025 and brought it back in June 2025. Read its current status before you use it.
- A binary cache service, such as Cachix or FlakeHub Cache, keeps packages outside GitHub. It helps most when you build packages on the runner, for example unfree ones. These services need an account and a token.
bothy is not tested with any of these cache tools. Measure a run with the cache and a run without it. A cache restore also takes time, and for a small store it can be slower than the download.
Self-hosted runners
A self-hosted runner keeps /nix and ~/.cache/nix between runs. You do not need a
Nix cache there. bothy reuses the packages, and a new checkout with the same
bothy.yaml starts with no download.