Examples

Complete bothy.yaml files to copy, from the smallest to the special cases.

Each example on this page is complete. To try one, make an empty directory, write the files that the example shows, and cd into the directory. bothy uses the closest bothy.yaml above the current directory, so each directory is its own worktree.

bothy explain shows what a directory will run. It starts nothing, so run it first.

example what it shows
A database and a cache The smallest useful file.
A Node web app A dev server, its databases, a migration, and the start order.
Django and Celery Two command services from one Python project.
Secrets A password that the committed file does not hold.
Separate secrets per service Two services that cannot read the credentials of each other.
A program that is not in nixpkgs A binary of your own, run as a service.
Mail and file storage Types with more than one port: mailpit and minio.

For CI workflows, see CI.

A database and a cache

A postgres and a redis, with no application. Write two files.

# bothy.yaml
services:
  db:
    type: postgres

  cache:
    type: redis

# Your shell gets:
#
#   BOTHY_DB_PORT       BOTHY_DB_URL       BOTHY_DB_DATA_DIR
#   BOTHY_CACHE_PORT    BOTHY_CACHE_URL    BOTHY_CACHE_DATA_DIR
#
# bothy does not invent `DATABASE_URL`. Ask for that name with `url_env:`.
#!/bin/sh
# check.sh: write to both services, with only the variables that bothy exports.
# psql and redis-cli come from the service packages, which bothy puts on PATH.
set -eu

psql "$BOTHY_DB_URL" -q -v ON_ERROR_STOP=1 <<'SQL'
SET client_min_messages TO warning;
CREATE TABLE IF NOT EXISTS visits (at timestamptz NOT NULL DEFAULT now());
INSERT INTO visits DEFAULT VALUES;
SQL
rows=$(psql "$BOTHY_DB_URL" -tA -c 'SELECT count(*) FROM visits')
hits=$(redis-cli -u "$BOTHY_CACHE_URL" INCR hits)

echo "postgres on port $BOTHY_DB_PORT: $rows row(s) in visits"
echo "redis on port $BOTHY_CACHE_PORT: hits = $hits"

Run it:

chmod +x check.sh
./check.sh

Make a second copy of the directory and run check.sh there. The second copy starts at one row, because it has its own postgres.

A Node web app

A Node server that counts visits in postgres and in redis. Write four files.

# bothy.yaml
tools:
  - nodejs_24
  - pnpm

services:
  db:
    type: postgres
    version: "16"
    # bothy exports the URL as BOTHY_DB_URL. This asks for a second name, because most
    # Node applications read DATABASE_URL.
    url_env: DATABASE_URL

  cache:
    type: redis
    url_env: REDIS_URL

  web:
    type: command
    # `command` goes through `sh`, so `$PORT` expands. Pass the port to the server.
    # Vite and Astro do not read $PORT, and take their default port instead. Then every
    # worktree gets the same port, and the second one fails.
    command: "pnpm dev --port $PORT"
    # Runs before each start, after db and cache are ready. Keep it cheap, because
    # bothy keeps no "already done" marker. A non-zero exit stops the start.
    setup: "pnpm install && pnpm migrate"
    port_env: PORT
    # web waits until db and cache pass their health checks.
    depends_on: [db, cache]
{
  "name": "node-web-app",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "node --watch server.js",
    "migrate": "node migrate.js"
  },
  "dependencies": {
    "pg": "8.23.1",
    "redis": "6.3.0"
  }
}

Save that as package.json. The migration runs before every start, so it is safe to run twice:

// migrate.js
import pg from "pg";

const db = new pg.Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
await db.query(`
  CREATE TABLE IF NOT EXISTS visits (
    id serial PRIMARY KEY,
    path text NOT NULL,
    at timestamptz NOT NULL DEFAULT now()
  )
`);
await db.end();
console.log("migrate: visits table is ready");
// server.js: reads only what bothy exports.
import http from "node:http";
import { parseArgs } from "node:util";
import pg from "pg";
import { createClient } from "redis";

const { values } = parseArgs({ options: { port: { type: "string" } } });
const port = Number(values.port ?? process.env.PORT ?? 3000);

const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const cache = await createClient({ url: process.env.REDIS_URL }).connect();

const server = http.createServer(async (req, res) => {
  if (req.url === "/favicon.ico") {
    res.writeHead(204).end();
    return;
  }
  await db.query("INSERT INTO visits (path) VALUES ($1)", [req.url]);
  const { rows } = await db.query("SELECT count(*)::int AS n FROM visits");
  const hits = await cache.incr("hits");
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ visits_in_postgres: rows[0].n, hits_in_redis: hits }) + "\n");
});

server.listen(port, "127.0.0.1", () => {
  console.log(`listening on http://127.0.0.1:${port}`);
});

cd out and back in. The first entry installs node, pnpm, postgres and redis. Then:

curl http://127.0.0.1:$PORT/
bothy logs web

Django and Celery

A Django server and a Celery worker in your own Django project. Each request puts a task on the redis queue, and the worker writes to postgres. Put this file beside manage.py:

# bothy.yaml
tools:
  - python312
  - uv

services:
  db:
    type: postgres
    url_env: DATABASE_URL
    # A database writes its state at shutdown. The default of 5 seconds is right for a
    # dev server and short for a database.
    stop_timeout_secs: 30

  cache:
    type: redis
    url_env: CELERY_BROKER_URL

  web:
    type: command
    command: "uv run manage.py runserver 127.0.0.1:$PORT"
    # `uv run` installs what `uv.lock` names first, so this one line sets up the
    # virtualenv and the database.
    setup: "uv run manage.py migrate"
    port_env: PORT
    depends_on: [db, cache]

  worker:
    type: command
    # `--pool=solo` runs tasks in the worker process. The default pool of Celery forks,
    # and that fails on macOS.
    command: "uv run celery -A config worker --pool=solo --loglevel=info"
    depends_on: [db, cache]
    # The worker has no port, so bothy cannot test a port. This asks the worker itself.
    health_check: "uv run celery -A config inspect ping"

Change config to the name of your Django settings package. Your settings must read DATABASE_URL and CELERY_BROKER_URL from the environment.

Secrets

A service that gets a key from a file that git ignores. Write three files.

# bothy.yaml
tools:
  - nodejs_24

services:
  web:
    type: command
    command: "node --watch server.js --port $PORT"
    port_env: PORT
    # `env:` is for configuration. Its values are literal and committed. A secret
    # belongs in `.env`, never here.
    env:
      NODE_ENV: development
# .env, which git ignores. bothy init adds it to .gitignore.
STRIPE_KEY=sk_test_123
// server.js: shows which secrets it got, never their values.
import http from "node:http";
import { parseArgs } from "node:util";

const { values } = parseArgs({ options: { port: { type: "string" } } });
const port = Number(values.port ?? process.env.PORT ?? 3000);

http
  .createServer((req, res) => {
    const key = process.env.STRIPE_KEY;
    res.writeHead(200, { "content-type": "text/plain" });
    res.end(`NODE_ENV: ${process.env.NODE_ENV}\nSTRIPE_KEY: ${key ? `set, ${key.length} characters` : "not set"}\n`);
  })
  .listen(port, "127.0.0.1");
curl http://127.0.0.1:$PORT/

bothy reads .env at entry and gives every value to your shell and to every service. If you edit .env, bothy restarts the services that read it, at your next prompt.

Separate secrets per service

Two services that get different secrets from one .env.

# bothy.yaml
secrets:
  # `source: .env` is the default. Name another file to use it, also a path outside
  # the repository, such as ~/.config/myapp/secrets.env.
  mode: explicit

tools:
  - nodejs_24

services:
  # A list, for a secret that keeps its own name.
  web:
    type: command
    command: "node web.js"
    port_env: PORT
    secrets:
      - STRIPE_KEY

  # A map, for a service that wants another name. `worker` reads DB_PASSWORD, and the
  # `.env` calls it PGPASS.
  worker:
    type: command
    command: "node worker.js"
    secrets:
      DB_PASSWORD: PGPASS
      SLACK_TOKEN: SLACK_TOKEN
# .env
STRIPE_KEY=sk_test_123
PGPASS=hunter2
SLACK_TOKEN=xoxb-example
// worker.js: logs which names it got, then waits until bothy stops it.
const names = ["STRIPE_KEY", "PGPASS", "SLACK_TOKEN", "DB_PASSWORD"];
console.log(names.map((n) => `${n}: ${process.env[n] ? "set" : "not set"}`).join("\n"));
setInterval(() => {}, 60_000);

Write a web.js in the same way, as an HTTP server on process.env.PORT. Then:

bothy logs worker

web sees STRIPE_KEY only. worker sees SLACK_TOKEN, and PGPASS under the name DB_PASSWORD. Your shell sees all three, so you can still run a command by hand.

A program that is not in nixpkgs

A database that nixpkgs does not have, run as a command service. Here it is kvd, a key-value server in vendor/bin/ of your project. bothy gives it a port, a data directory, a setup step, a health check and a URL, as it does for postgres.

# bothy.yaml
#
# A vendor install outside the repository goes on PATH as a directory. bothy does not
# install it, does not pin it, and cannot promise that it exists on another machine.
# Use a nixpkgs package when there is one.
#
# tools:
#   - path: /opt/kvd/bin

services:
  kvd:
    type: command
    # Commands run from the directory that holds this file, so a relative path works.
    command: "vendor/bin/kvd serve --port $PORT --data $BOTHY_KVD_DATA_DIR"
    setup: "vendor/bin/kvd migrate --data $BOTHY_KVD_DATA_DIR"
    port_env: PORT
    # kvd is a node script, so it needs node. `runtime` puts one package on the PATH of
    # this service, and your shell gets it too.
    runtime: nodejs_24
    # Without this, bothy connects to the port to decide that the service is ready.
    # That is right for a dev server and wrong for a database, which can accept
    # connections before it can serve one.
    health_check: "vendor/bin/kvd ping --port $PORT"
    stop_timeout_secs: 30
    # `{port}` becomes the allocated port. bothy exports this as BOTHY_KVD_URL.
    url: "http://127.0.0.1:{port}"
    url_env: KVD_URL

  report:
    type: command
    command: "node report.js"
    runtime: nodejs_24
    depends_on: [kvd]

Change kvd and its flags to your own program. Each service gets its own BOTHY_<NAME>_DATA_DIR, so the data of each worktree stays separate.

Mail and file storage

A server that stores a file in minio and mails a note about it to mailpit. Both types listen on two ports, and bothy exports each one by name.

# bothy.yaml
tools:
  - nodejs_24
  - pnpm

services:
  mail:
    type: mailpit

  storage:
    type: minio

  web:
    type: command
    command: "pnpm dev --port $PORT"
    setup: "pnpm install"
    port_env: PORT
    depends_on: [mail, storage]
    # The minio type starts the server with these fixed development credentials. They
    # are not secrets, so they are here and not in `.env`.
    env:
      S3_ACCESS_KEY: bothyadmin
      S3_SECRET_KEY: bothyadmin

# Your shell and every service get, for mail:
#
#   BOTHY_MAIL_PORT        the primary port, what an application connects to
#   BOTHY_MAIL_SMTP_PORT   the same port again, under the name of its role
#   BOTHY_MAIL_HTTP_PORT   the other port
#   BOTHY_MAIL_URL         the address that a person opens in a browser
#
# storage gets the same set, with API and CONSOLE in place of SMTP and HTTP.

Your application sends mail to 127.0.0.1:$BOTHY_MAIL_SMTP_PORT and stores files at $BOTHY_STORAGE_URL. To see the mail, open the mailpit page:

open $BOTHY_MAIL_URL

The minio console is at http://127.0.0.1:$BOTHY_STORAGE_CONSOLE_PORT. Its user and password are both bothyadmin.