Self-hosting Spend Brake

A single Docker host runs everything: the proxy (what apps and agents call), the admin API, the worker, the notifier, the dashboard, Postgres and Valkey. This guide covers going from the 5-minute quickstart to something you can depend on. Every setting is listed in Configuration.

Try it locally in 5 minutes

Requirements: Docker, Node 22+ (for the CLI), a Google Maps Platform key.

git clone https://github.com/KIvanow/spend-brake && cd spend-brake
cp infra/.env.example infra/.env
# fill in the secrets, e.g.:
sed -i "s|^BRAKE_MASTER_KEY=.*|BRAKE_MASTER_KEY=$(openssl rand -base64 32)|; \
        s|^BRAKE_ACTION_SECRET=.*|BRAKE_ACTION_SECRET=$(openssl rand -base64 32)|; \
        s|^BRAKE_SETUP_TOKEN=.*|BRAKE_SETUP_TOKEN=$(openssl rand -hex 24)|; \
        s|^VALKEY_PASSWORD=.*|VALKEY_PASSWORD=$(openssl rand -hex 24)|; \
        s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 24)|" infra/.env

docker compose -f infra/docker-compose.yml up -d --build --wait

That starts the proxy (:8080), admin API (:8081), notifier (:8082), dashboard (:3000), worker, Valkey and Postgres. Valkey and Postgres are only reachable from the other containers. Then set up your org, store the Google key and get a virtual key:

pnpm install && pnpm --filter @spend-brake/cli build
alias brake="node $PWD/apps/cli/dist/brake.cjs"

# Secrets from the environment (or omit them to be prompted): flags would land in shell history.
export BRAKE_SETUP_TOKEN="$(grep BRAKE_SETUP_TOKEN infra/.env | cut -d= -f2)"
read -rs GOOGLE_MAPS_KEY && export GOOGLE_MAPS_KEY
brake init --api-url http://localhost:8081 --proxy-url http://localhost:8080 \
  --org acme --day-cap 5 --month-cap 50

init prints the owner token once (keep it in a password manager) and saves an admin token for the CLI in ~/.config/brake/config.json. It also prints your virtual key once, checks whether the Google key is restricted, and prints the Google Cloud steps (console and gcloud) to restrict the real key to the proxy’s egress IPs and the Maps APIs. Then call the proxy instead of Google:

- https://maps.googleapis.com/maps/api/geocode/json?address=Sofia&key=AIza…
+ http://localhost:8080/google-maps/maps/api/geocode/json?address=Sofia&key=sb_live_…

Open the dashboard at http://localhost:3000 with the owner token, or the admin token brake init saved.

The CLI and the Docker images are built from source for now; published packages (npx @spend-brake/cli) and images are coming.

1. Install

Copy infra/.env.example to infra/.env and fill it in (the file says how to generate each secret). Keep BRAKE_MASTER_KEY somewhere outside the host as well: provider keys and webhook URLs are encrypted with it and cannot be recovered without it.

Build the images from source and start the stack:

F="-f infra/docker-compose.yml -f infra/docker-compose.tls.yml"
docker compose --env-file infra/.env $F build
docker compose --env-file infra/.env $F up -d --wait

Published images are coming; until then there is nothing to pull, and infra/docker-compose.release.yml is for when they are. Business / Enterprise: add -f infra/docker-compose.ee.yml right after the base file (see ee/README.md).

Then build the CLI (pnpm install && pnpm --filter @spend-brake/cli build, which writes apps/cli/dist/brake.cjs; published packages are coming too), run node apps/cli/dist/brake.cjs init once (it uses BRAKE_SETUP_TOKEN), and remove the setup token from infra/.env afterwards.

People sign in to the dashboard with an API token by default. With email set up (section 2), set BRAKE_EMAIL_SIGN_IN=true and invite your team under Team: each person gets a one-time sign-in link by email instead of a token to keep.

2. HTTPS

infra/docker-compose.tls.yml puts Caddy in front with certificates from Let’s Encrypt, renewed automatically. Set in infra/.env:

Variable Example What
BRAKE_PROXY_DOMAIN brake.example.com The base URL apps and agents use instead of the provider’s
BRAKE_APP_DOMAIN app.example.com Dashboard, admin API (/v1), alert links (/a/), SSO
ACME_EMAIL ops@example.com Let’s Encrypt notices
BRAKE_HTTP(S)_PORT 80 / 443 (default) Published ports, if something else holds 80/443

Both names must resolve to the host and ports 80 and 443 must be reachable for the certificate challenge. With this overlay only Caddy publishes ports; the services’ own ports are closed, and the proxy and API trust Caddy’s X-Forwarded-For (Caddy drops one sent by a client). Point the CLI at https://app.example.com (API) and https://brake.example.com (proxy). SAML single sign-on needs HTTPS.

Email alerts and license emails go out through SMTP_URL (for example smtps://user:password@smtp.example.com:465) from BRAKE_EMAIL_FROM, on a domain your mail provider has verified. Some hosts block outgoing ports 25, 465 and 587 (DigitalOcean does on new accounts); use the provider’s alternative port, such as 2465 or 2587, if they offer one.

3. Make the proxy the only way to spend

Caps only hold if nothing reaches the provider without the proxy. Give the proxy a fixed outbound IP address and restrict each provider key to it (brake init prints the Google Cloud steps for Google keys):

  • On a single VM, its public IP is usually fixed; check it from inside the proxy container: docker compose exec proxy node -e "fetch('https://api.ipify.org').then(r=>r.text()).then(console.log)".
  • Behind a cloud NAT (AWS NAT Gateway with an Elastic IP, GCP Cloud NAT with a static address, Azure NAT Gateway), use the NAT’s address.
  • Where a provider has no IP restriction (several search APIs), keep the key only in Spend Brake, rotate it if it was ever used elsewhere, and keep the provider’s own spend limit on as a backstop.

4. Check prices against your real bills

Before relying on the caps, run one cheap call per provider through your install and compare:

BRAKE_API_URL=https://app.example.com BRAKE_PROXY_URL=https://brake.example.com \
BRAKE_ADMIN_TOKEN=sbt_... GOOGLE_MAPS_KEY=... MAPBOX_TOKEN=... EXA_API_KEY=... \
node scripts/check-providers.mjs

It creates a temporary key with a $0.50 daily cap per provider, makes the call, prints what the proxy charged next to what the provider reported (Tavily, Exa), and removes everything it created. A few cents in total. The next day, compare the charges with each provider’s usage page; a difference means a catalog price to correct. For Twilio, send one SMS to your own phone through the proxy and compare the settled price (the adjustment event) with Twilio’s console. For Google, connect the billing export (Business / Enterprise) and reconciliation does this daily.

5. Backups

Postgres holds configuration, the ledger and the audit log; Valkey holds live spend counters, reservations and kill switches. Back up both, e.g. hourly from cron, and copy the result off the host:

COMPOSE_FILES="-f infra/docker-compose.yml -f infra/docker-compose.tls.yml" scripts/backup.sh

Restore replaces the data of a running install (services stop for the swap, then restart):

COMPOSE_FILES="..." scripts/restore.sh backups/20261030T120000Z

Spend recorded after the backup was taken is not in it: after a restore, live counters stand where they were at backup time, so caps can let that much through again. The backup contains encrypted credentials; it is useless without BRAKE_MASTER_KEY, which is not in it.

6. Upgrades

  1. Read the release notes in CHANGELOG.md.
  2. Back up (section 5).
  3. git pull the release, then docker compose ... build && docker compose ... up -d --wait.

The API and worker apply database migrations on start (one at a time, under a lock).

If the release changes infra/Caddyfile, recreate Caddy after up: docker compose ... up -d --force-recreate caddy. The Caddyfile is mounted as a single file, and git pull replaces it with a new file rather than editing it in place, so the container keeps seeing the old one and caddy reload reloads stale settings. (A directory mount would avoid this, but it would move the file existing installs have customized; recreating is one command.)

Upgrading and rolling back

  • Back up first, every time (section 5). The backup is your rollback.
  • Roll back by restoring that backup into the previous version:
    1. Check out the previous release and build its images (docker compose ... build, or load them if you keep release images). Don’t up yet: the running services are not touched.
    2. Run scripts/restore.sh backups/<timestamp>. It stops the services, swaps the data in and starts them again, now on the previous release’s images, so the newer version never runs on the restored data. To start them yourself instead, run it with NO_START=1.
  • Keys created after the backup are not in it and stop working (401) after the rollback; create them again. Changes made since (caps, kills, names, members) are undone too.
  • Starting the old images on the upgraded data is not a rollback. Migrations only move forward, and a newer API rewrites the Valkey records the proxy reads at start-up. Builds from this release on ignore record fields they don’t know, but older builds may refuse them and block every call.
  • Spend recorded since the backup is not in it (section 5), so caps can let that much through again after the restore.

7. Scaling and high availability

  • Proxy: stateless; run several with docker compose ... up -d --scale proxy=3. Caddy spreads requests across them and skips one that fails its health check.
  • Worker: run several; they share the ledger stream through a consumer group, and alerts are deduplicated, so a second worker takes over if one stops.
  • Valkey: caps are only as available as Valkey. For high availability use a Valkey Cluster and set VALKEY_URL=redis-cluster://:password@valkey-1:6379,valkey-2:6379,valkey-3:6379 (rediss-cluster:// for TLS); every key of an org lives in one slot, so budget checks stay atomic. When Valkey is unreachable the proxy refuses requests (fail closed) unless a key has a fail-open allowance.
  • Postgres: use a managed Postgres or a replicated setup for durability; the proxy keeps enforcing caps during a Postgres outage (config is cached in Valkey), while the API and the ledger wait for it.

8. Monitoring

What Where
Health GET /healthz on the proxy, API and notifier
Metrics (Prometheus) proxy :9465/metrics, worker :9464/metrics (inside the network)
Traces (OpenTelemetry) set OTEL_EXPORTER_OTLP_ENDPOINT on each service
Alerts worth paging on brake_reservations_expired_total rising, brake_ledger_lag_events growing, brake_worker_last_loop_timestamp_seconds stale, upstream_key_error and reconcile_drift alerts

Each org’s own telemetry

Separate from the operator metrics above, every org gets telemetry for its own calls (details in Telemetry):

  • Prometheus: GET https://<app domain>/v1/metrics with any of the org’s tokens (create a read-only one under Telemetry). Requests, cost and refunds by provider, SKU, key, agent, environment and outcome (including refused calls), request and provider latency histograms, and what is left of each cap.
  • OpenTelemetry: under Telemetry, an org sets an OTLP/HTTP endpoint and headers (stored encrypted); the worker sends every call as a span, inside the caller’s trace when the request carried a traceparent header. A collector that is down delays spans (up to 10,000 wait per org); private addresses are refused (ALLOW_PRIVATE_COLLECTORS=true on the worker and the API allows them, and a local http:// collector, for development). On the hosted service this comes with the Team and Agents plans.

Neither ever includes query strings or request bodies: those hold addresses, phone numbers and search terms.