Running your API collections straight from the command line is one of Bruno's most-loved capabilities, and teams lean on the bru CLI for everything from a quick local spot-check to a full regression suite in CI. Today we are making that power even easier to put on autopilot by taking the setup work, installing the CLI on every runner, wiring up reports, handling secrets, off your plate. Bruno now ships an official Docker image and an official GitHub Action, both generally available.
Table of Contents
Docker image · usebruno/cli
A container with bru baked in. Runs your collection anywhere Docker runs (your laptop, GitLab, Jenkins, Azure DevOps, a Kubernetes job) with no Node.js or npm on the host.
GitHub Action · usebruno/bruno-cli-action
A thin, pass-through Action for GitHub workflows. It installs the CLI, runs your command, and exposes machine-readable counts so the rest of your pipeline can react.
Both share the same goal of automating your API tests, but they are built for different setups. The Docker image is the universal option: it works on every CI system and locally. The GitHub Action is the GitHub-native convenience: less YAML, plus outputs that plug straight into the rest of the Actions ecosystem. This post covers both, with plenty of worked examples: data-driven runs, secret handling, mutual TLS, read-only containers, and threshold gating.
Both are wrappers around the same command: bru run. If the CLI has ever confused you, four ideas explain almost everything.
1. You run from the collection root. A Bruno collection is a directory containing an opencollection.yml config file, your request files, and an environments/ folder. bru run with no arguments executes every request in the current directory's collection. Point it at a subfolder or a single file to narrow the scope:
bru run # every request in this collection
bru run ./auth # just the requests in the auth/ folder
bru run ./auth/login.yml # a single request file
bru run ./auth ./billing -r # two folders, recursing into subfolders
The -r flag means recursive. Without it, bru run ./auth executes only the requests directly inside auth/ and ignores nested folders. Add -r whenever your folders have folders.
2. Environments select your variables. --env staging loads environments/staging.yml, which is where your base URLs and variables might live. Switching environments allows the same collection to easily hit staging in one run and production in the next.
3. Reporters write results to disk. The CLI prints a human summary to stdout, but CI needs a file. --reporter-junit results.xml emits JUnit XML; --reporter-html and --reporter-json exist too. You can pass several at once.
Heads up: Older examples use --output results.xml --format junit. Those flags (-o / -f) are now deprecated. Use --reporter-junit, --reporter-html, and --reporter-json instead. Every example in this post uses the current flags.
4. The exit code is the contract. bru run exits 0 when everything passes and non-zero when anything fails. That is what makes a pipeline step go red. You rarely set this yourself (you just let it propagate), but knowing the specific codes (table at the end) turns cryptic CI failures into one-line fixes.
The image bundles bru on top of Node 22 so you never install anything on the host. A few details that matter in practice:
bru and the working directory is /bruno. You mount your collection to /bruno and everything after the image name is passed straight to bru.alpine (default, node:22-alpine, ~133 MB) for almost everyone; debian (node:22-slim) if you hit a glibc or SSL edge case.usebruno/cli) or GHCR (ghcr.io/usebruno/cli); the images are identical.node, UID 1000) and multi-arch (linux/amd64, linux/arm64), so it runs natively on Apple Silicon and ARM runners.# from inside your collection directory
docker run --rm -v $(pwd):/bruno usebruno/cli run
# a subfolder, against the staging environment, recursing
docker run --rm -v $(pwd):/bruno usebruno/cli run ./smoke --env staging -r
What the parts do: -v $(pwd):/bruno bind-mounts your current directory into the container's working directory so bru can see your request files (this is necessary); --rm deletes the container the moment it exits (worth making a habit in CI so stopped containers don't pile up); everything after usebruno/cli is ordinary bru run syntax.
Cross-platform tip. $(pwd) works in Bash, Zsh, Git Bash, and WSL. On Windows use ${PWD} in PowerShell or %cd% in CMD.
One of the most underused CLI features is running the same collection once per row of a data file. This is how you turn one login test into a hundred-account login test, or drive a parameterized contract test from a fixtures file.
docker run --rm \
-v $(pwd):/bruno \
usebruno/cli run ./checkout \
--csv-file-path ./data/test-accounts.csv \
--reporter-junit results.xml
--csv-file-path (or --json-file-path for JSON fixtures) runs the target request/folder once for each record, exposing the row's columns as variables inside your requests. Pair it with --iteration-count 5 to repeat the whole run a fixed number of times, which is useful for shaking out flaky endpoints or warming a cache before assertions. If the data file is inside the mounted directory, it is visible to the container with no extra flags.
For security-sensitive pipelines you can run the image with the host filesystem mounted read-only and a writable tmpfs only where reports are written. Nothing the collection does can mutate your checkout:
docker run --rm \
--read-only \
--tmpfs /tmp \
-v $(pwd):/bruno:ro \
-v $(pwd)/reports:/reports \
--network none \
usebruno/cli run --env ci --reporter-junit /reports/results.xml
Here :ro mounts your collection read-only, --read-only locks the container's own filesystem, --tmpfs /tmp gives bru a scratch space, and the separate /reports mount is the one writable path. (Drop --network none for real runs; it is shown to make the point that the container only gets the access you grant it.) The image already runs as non-root, so you are layering defense on a sane default.
| Tag | Behaviour | Use when |
|---|---|---|
usebruno/cli:latest |
Newest release (alpine) | Local experimentation |
usebruno/cli:3.3 |
Floats with 3.3.x patches | You want bug fixes but not minor bumps |
usebruno/cli:3.3.0 |
Immutable exact version | Production CI, reproducible runs |
...@sha256:… |
Immutable digest | Strict supply-chain policies |
For CI, pin to an exact version (3.3.0) or a digest. latest is convenient locally but means your pipeline can change underneath you.
If your CI platform is GitHub, the Action is less to write and gives you outputs the rest of your workflow can branch on. By design it is a thin pass-through: it installs @usebruno/cli, runs whatever you put in command, and parses the JUnit report into numbers. It deliberately does not mirror every CLI flag: you pass them through command verbatim, so any flag works the day the CLI ships it.
Inputs
| Input | Required | Default | What it does |
|---|---|---|---|
command |
yes | (none) | The bru subcommand and flags, e.g. run --env prod. The Action prepends bru and auto-injects --reporter-junit if you omit it. |
working-directory |
no | . |
Where to run, usually your collection root. |
bru-version |
no | latest |
Which CLI version to install. |
Outputs
Available as $: exit-code, passed, failed, total, and duration-ms. These are the hook for everything interesting downstream.
- uses: usebruno/bruno-cli-action@v1
with:
working-directory: tests/payments
command: 'run --env prod'
That is the whole thing. The step goes red on an assertion failure, green on success. Because --reporter-junit is auto-injected, the count outputs are populated even though you never named a report file.
The Action's step already fails on any failure. But sometimes you want a softer gate (block the merge only if more than two requests fail) that the raw exit code can't express. The failed and total outputs make this trivial:
- id: bruno
uses: usebruno/bruno-cli-action@v1
continue-on-error: true # don't let the step itself fail the job yet
with:
working-directory: tests/payments
command: 'run --env prod --tags smoke'
- name: Enforce failure budget
if: steps.bruno.outputs.failed != '0'
run: |
echo "::error::$/$ requests failed"
if [ "$" -gt 2 ]; then
echo "Failure budget exceeded, blocking."
exit 1
fi
echo "Within budget, allowing with a warning."
continue-on-error: true keeps the Bruno step from failing the job immediately, so your custom logic gets to run; then you decide the verdict from the outputs. Swap the threshold for a percentage with a little shell arithmetic if you prefer a ratio.
The Action isn't only for pull requests. Put it on a schedule trigger and it becomes a lightweight synthetic monitor that runs your smoke suite against production every few minutes and pings you on failure:
on:
schedule:
- cron: '*/15 * * * *' # every 15 minutes
jobs:
monitor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: bruno
uses: usebruno/bruno-cli-action@v1
continue-on-error: true
with:
working-directory: tests/payments
command: 'run --env prod --tags smoke --bail'
- if: steps.bruno.outputs.failed != '0'
run: ./notify-oncall.sh "$ checks failing in $0ms"
Here --tags smoke keeps the monitor to a fast, safe, read-only subset (more on tags below) and --bail stops at the first failure so a broken production endpoint pages you in seconds rather than after the whole suite drains.
This is the part the docs spread across many pages. Each recipe below works identically whether you put it in the Docker run arguments or the Action's command: it is all just bru run.
bru run --tags smoke # only requests tagged "smoke"
bru run --tags smoke,auth # requests tagged BOTH smoke AND auth
bru run --exclude-tags destructive,slow # everything EXCEPT those tags
--tags runs only requests carrying all of the listed tags; --exclude-tags skips any request carrying any of them. The pattern that pays off: tag your fast, read-only checks smoke and your data-mutating ones destructive. Then PRs run --tags smoke, the nightly job runs the full suite, and a production monitor runs --exclude-tags destructive so it never writes to live data.
bru run --bail # stop at the first failed request/test/assertion
bru run --tests-only # skip requests that have no tests or assertions
Use --bail for monitors and pre-push hooks where you want the first red light immediately. Omit it in PR runs where you want the complete list of failures in one report. --tests-only is handy when a collection mixes setup/seed requests with actual assertions and you only want the assertions to count.
bru run --delay 250 # wait 250ms between requests
bru run --parallel # fire requests concurrently
--delay spaces requests out so you don't trip a rate limiter on a shared staging API. --parallel does the opposite: it runs requests concurrently to finish faster. Reach for --delay against fragile or rate-limited targets; reach for --parallel on independent, idempotent checks where wall-clock time matters. They pull in opposite directions, so pick one per run.
bru run --env staging \
--env-var host=https://canary.internal \
--env-var apiVersion=v2
--env-var name=value overrides a single variable from the loaded environment, and you can pass it as many times as you like. This is the clean way to point an existing collection at a one-off host (a canary, a feature-branch deployment) without committing a new environment file. It is also, as we'll see next, how you inject secrets safely.
API tests touch tokens, cookies, and internal certificate authorities. Getting this wrong leaks credentials into build logs and report artifacts. Here is how to get it right.
The most robust pattern keeps the secret in a process environment variable and references it from your collection, so the raw value never appears in a committed file or a command string. Use wherever the request needs the secret (for example, in an Authorization header or your auth config), then pass the real value into the container or job from your CI's secret store. With Docker, -e API_TOKEN forwards the host variable without ever writing its value on the command line:
docker run --rm \
-v $(pwd):/bruno \
-e API_TOKEN \
usebruno/cli run --env ci --reporter-junit results.xml
In the GitHub Action, do the same with an env: block sourced from secrets:
- uses: usebruno/bruno-cli-action@v1
env:
API_TOKEN: $
with:
working-directory: tests/payments
command: 'run --env ci'
Why not --env-var token=$SECRET? Your shell expands that before Docker or the runner sees it, so the literal secret can land in process listings and verbose logs. Forwarding the variable and reading avoids that exposure entirely. GitHub also auto-masks registered secrets in logs, but defense in depth beats relying on masking alone.
JUnit and HTML reports capture request and response detail by default, which means an Authorization header or a token in a response body can end up in an artifact your whole org can download. Strip them:
bru run --env ci --reporter-junit results.xml \
--reporter-skip-headers "Authorization Cookie X-Api-Key" \
--reporter-skip-response-body
--reporter-skip-headers takes a list of header names to redact. Related switches give you broader hammers: --reporter-skip-all-headers drops every header, --reporter-skip-request-body / --reporter-skip-response-body drop bodies, and --reporter-skip-body is the shorthand for both bodies. Redact the minimum you need to stay readable while keeping credentials out of the artifact.
Testing an internal API behind mTLS or a corporate CA is a common enterprise need. Marshal the certs from secrets into temp files, then point bru at them:
# GitHub Actions
- name: Materialize certs
run: |
mkdir -p /tmp/certs && chmod 700 /tmp/certs
echo "$CA_CERT" > /tmp/certs/ca.pem
echo "$CLIENT_CFG" > /tmp/certs/client.json
env:
CA_CERT: $
CLIENT_CFG: $
- uses: usebruno/bruno-cli-action@v1
with:
working-directory: tests/payments
command: 'run --env prod --cacert /tmp/certs/ca.pem --client-cert-config /tmp/certs/client.json'
--cacert supplies the CA used to verify the server; --client-cert-config points to the client-certificate config Bruno uses to authenticate itself. If your internal CA should be the only trusted root, add --ignore-truststore so the system trust store is bypassed entirely. Reserve --insecure (skip TLS verification altogether) for throwaway self-signed staging boxes, never production, and use --noproxy when a CI proxy is interfering with a direct internal call.
Since Bruno CLI v3, scripts run in safe mode by default. If your pre-request or test scripts use Node built-ins (require, Buffer, the filesystem) or external npm packages, those calls will silently do nothing until you opt in with --sandbox developer. It is a frequent "my tests pass locally but fail in CI" culprit. Enable it only when a collection genuinely needs it; safe mode exists precisely to limit what untrusted scripts can do.
When a run fails, the exit code (surfaced by the Action as outputs.exit-code) usually tells you exactly what to fix. The most common ones:
| Code | Meaning | Fix |
|---|---|---|
| 0 | Everything passed | n/a |
| 1 | A request, test, or assertion failed | Inspect the report; fix the test |
| 2 | Reporter output directory doesn't exist | Create the dir or fix the --reporter-* path |
| 4 | Run started outside a collection root | Set working-directory / mount to the collection |
| 6 | Environment file not found | Check environments/<env>.yml exists |
| 9 | Invalid reporter format | Only json / junit / html |
| 137 | Killed by the OS (usually OOM) | Bigger runner, or split the collection |
A revealing case: exit-code non-zero but failed is 0 means bru crashed before writing results, so treat it as a runtime error and read stderr, not as a test failure. (The full table lives in the Action's README.)
Use the GitHub Action if your CI is GitHub and you want the least YAML plus ready-made outputs for gating, comments, and notifications. Use the Docker image everywhere else (GitLab CI, Jenkins, Azure DevOps, Bitbucket, a local pre-push hook, or a Kubernetes job) and whenever you want byte-for-byte identical runs locally and in the pipeline. Many teams use both: the image for local and non-GitHub pipelines, the Action for their GitHub PR gate. Under the hood they run the same bru, so a command you debug in one works in the other.
# pull the image and confirm it works
docker pull usebruno/cli:latest
docker run --rm usebruno/cli --version
# run your collection
cd path/to/your/collection
docker run --rm -v $(pwd):/bruno usebruno/cli run --env staging
Or, on GitHub, commit a workflow at .github/workflows/api-tests.yml to run your collection on every pull request:
name: API Tests
on: [pull_request]
jobs:
bruno:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: usebruno/bruno-cli-action@v1
with:
working-directory: tests/payments
command: 'run --env ci --reporter-junit results.xml'
- uses: actions/upload-artifact@v4
if: always()
with:
name: bruno-report
path: tests/payments/results.xml
actions/checkout pulls your repo (and its collection) onto the runner; working-directory points the Action at your collection root; and command is the same bru run syntax you would use anywhere else, here writing a JUnit report. The final actions/upload-artifact step saves that report as a downloadable artifact on the run page, and if: always() makes sure it uploads even when tests fail (which is exactly when you want to read it). Open a PR and the step reports pass or fail right in the Checks tab.
Both are GA, officially maintained, and published on every release. We would love your feedback; tell us what your pipeline needs next.
Links: Docker image · GitHub Action · Bruno CLI docs · Bruno on GitHub
Happy testing 🚀