Bruno: The Open-Source Postman Alternative That Stores Collections in Git
In 2023, Postman announced it was sunsetting its free Scratch Pad — the local-only workspace that kept collections entirely on your machine. If you wanted to use Postman, you had to sync everything to Postman's cloud. For teams that work with sensitive APIs, financial data, or internal microservices, that was a deal-breaker. Bruno was built as a direct response to that shift.
Bruno is an open-source API client that stores collections as plain text files on your filesystem. There is no cloud sync, no account required, no telemetry by default. Your collections live alongside your code in Git, get reviewed in pull requests, and travel with the repository. This guide covers everything you need to know to adopt Bruno — installing it, writing requests, using environments, scripting tests in JavaScript, and running everything from the CLI in CI pipelines.
Why Bruno Exists
The Postman controversy crystallized a frustration many developers had quietly accepted: API collections should be files, not cloud objects. When your Postman workspace lives in Postman's cloud, you can't diff it, branch it, or review it the same way you review code.
Bruno's creator, Anoop M D, launched it in 2022 with a clear philosophy: the collection is the source of truth, it lives on disk, and nothing leaves your machine unless you push it. The project gained tens of thousands of GitHub stars within months. By 2024, it had become one of the fastest-growing API tools in the open-source ecosystem.
The format Bruno uses is called Bru — a domain-specific language that looks like a simplified plaintext config. Each request is one .bru file. Collections are directories. Environments are .bru files in a .env subdirectory. The entire collection structure maps cleanly to a directory tree you can commit.
Installing Bruno
Bruno ships as a desktop app (Electron-based) for macOS, Linux, and Windows.
macOS via Homebrew:
brew install --cask brunomacOS/Windows: Download directly from usebruno.com. There are both stable and nightly builds.
Linux: Bruno provides AppImage and .deb packages. For Debian/Ubuntu:
sudo <span class="hljs-built_in">mkdir -p /etc/apt/keyrings
curl -fsSL https://www.usebruno.com/downloads/deb/pubkey.gpg <span class="hljs-pipe">| <span class="hljs-built_in">sudo gpg --dearmor -o /etc/apt/keyrings/bruno.gpg
<span class="hljs-built_in">echo <span class="hljs-string">"deb [signed-by=/etc/apt/keyrings/bruno.gpg] https://www.usebruno.com/downloads/deb stable main" <span class="hljs-pipe">| <span class="hljs-built_in">sudo <span class="hljs-built_in">tee /etc/apt/sources.list.d/bruno.list
<span class="hljs-built_in">sudo apt-get update
<span class="hljs-built_in">sudo apt-get install brunoThe Bruno CLI (@usebruno/cli) is separate from the desktop app and is what you use in CI:
npm install -g @usebruno/cli
# or
bun add -g @usebruno/cliCreating Your First Collection
Open Bruno and click Create Collection. Give it a name — Bruno will ask where to save it. Pick a directory inside your project repository, such as api-tests/ or bruno/. Bruno creates a directory structure like:
bruno/
├── .env/
│ ├── local.bru
│ └── staging.bru
├── auth/
│ └── login.bru
├── users/
│ ├── get-user.bru
│ ├── create-user.bru
│ └── delete-user.bru
└── bruno.jsonbruno.json is the collection manifest. It contains the collection name and version. Everything else is a .bru file.
Writing Requests in Bru Format
When you create a request in the GUI, Bruno writes a .bru file to disk automatically. But you can also write .bru files by hand — they're readable text. Here's what a GET request looks like:
meta {
name: Get User
type: http
seq: 1
}
get {
url: {{baseUrl}}/users/{{userId}}
body: none
auth: bearer
}
auth:bearer {
token: {{authToken}}
}
headers {
Accept: application/json
X-Request-ID: {{$randomUUID}}
}And a POST request with a JSON body:
meta {
name: Create User
type: http
seq: 2
}
post {
url: {{baseUrl}}/users
body: json
auth: bearer
}
auth:bearer {
token: {{authToken}}
}
headers {
Content-Type: application/json
}
body:json {
{
"email": "{{userEmail}}",
"name": "Test User",
"role": "viewer"
}
}Variables use the {{variableName}} syntax, consistent with Postman. Built-in dynamic variables like {{$randomUUID}} and {{$timestamp}} are supported.
Working with Environments
Bruno environments are .bru files stored in the .env/ folder inside your collection. Here's a local.bru environment:
vars {
baseUrl: http://localhost:3000
userEmail: test@example.com
}
vars:secret [
authToken
]The vars:secret block lists variables that Bruno treats as secrets — it will mask them in logs and never write their values to the .bru file. You provide secret values at runtime or through a .env override file that you add to .gitignore.
Switch environments in the GUI via the dropdown in the top-right corner. From the CLI, pass --env local or --env staging.
You can also set a root-level .env file at the collection root (not the .env/ directory — an actual .env file, gitignored) that provides secret values for any environment:
authToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Writing Tests in JavaScript
Bruno has a full scripting layer. Each request can have a script:pre-request block (runs before the request) and a script:post-response block (runs after). Tests go in a tests block. All three use JavaScript.
tests {
test("status is 200", function() {
expect(res.status).to.equal(200);
});
test("response has user id", function() {
const body = res.getBody();
expect(body).to.have.property("id");
expect(body.id).to.be.a("string");
});
test("response time under 500ms", function() {
expect(res.responseTime).to.be.below(500);
});
}Bruno's test assertions use the Chai expect API, which many JavaScript developers already know. The res object gives you access to:
res.status— HTTP status coderes.headers— response headersres.getBody()— parsed response bodyres.responseTime— time in milliseconds
Pre-Request Scripts and Variable Chaining
Real API workflows require chaining — you log in, capture the token, and pass it to subsequent requests. Here's how to do that in Bruno.
In your login request, add a post-response script:
script:post-response {
const data = res.getBody();
bru.setVar("authToken", data.token);
}bru.setVar() sets a collection-level variable that's available to all subsequent requests in the same run. Other useful bru APIs:
bru.getVar("name")— read a collection variablebru.setEnvVar("name", "value")— set an environment variablebru.getEnvVar("name")— read an environment variablebru.setNextRequest("request-name")— control execution order in a runner
Pre-request scripts use the same API:
script:pre-request {
// Generate a timestamp header value
const ts = new Date().toISOString();
bru.setVar("requestTimestamp", ts);
}Running Collections from the CLI
The Bruno CLI (bru) lets you run entire collections in CI pipelines without opening the GUI.
Basic run:
bru run --env stagingThis runs all requests in the collection against the staging environment, in the order defined by seq in each .bru file's meta block.
Run a specific folder:
bru run auth/ --env <span class="hljs-built_in">localRun a single request:
bru run auth/login.bru --env stagingExport results as JUnit XML (for CI test reporters):
bru run --env staging --reporter-junit results.xmlGitHub Actions example:
name: API Tests
on: [push, pull_request]
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install -g @usebruno/cli
- name: Run Bruno tests
run: bru run --env staging --reporter-junit results.xml
working-directory: ./bruno
env:
authToken: ${{ secrets.AUTH_TOKEN }}
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: Bruno API Tests
path: bruno/results.xml
reporter: java-junitThe secret authToken is passed as an environment variable. Bruno picks it up from the process environment and merges it with variables declared as vars:secret in your environment file.
Git-Friendly Workflow in Practice
Because Bruno stores everything as text files, your API testing workflow becomes part of your normal Git workflow.
Typical team workflow:
- Developer adds a new endpoint to the API
- Developer creates or modifies a
.brufile for that endpoint - The
.brufile is committed alongside the API code in the same PR - CI runs
bru run --env stagingagainst the PR's deployed preview environment - Reviewer can see exactly what changed in the API test by reading the
.brufile diff
The diff is readable. A change from GET to POST, a new header, an updated assertion — all of it shows up as clean text diffs in your pull request. This is fundamentally different from Postman's JSON collection format, which produces large noisy diffs even for small changes.
What to commit:
- All
.brurequest files - Environment
.brufiles (with secrets listed invars:secret, not with actual secret values) bruno.json
What to gitignore:
.envfile at collection root (contains actual secret values)bru.jsonif it contains workspace-level settings you don't want shared
Comparison with Postman and Insomnia
| Feature | Bruno | Postman | Insomnia |
|---|---|---|---|
| Open source | Yes (MIT) | No | Yes (Apache 2.0) |
| Cloud sync required | No | Yes (free tier) | Optional |
| Collection storage | Plain text files | Postman cloud or JSON export | SQLite or Git |
| Scripting language | JavaScript (Chai) | JavaScript (Chai) | JavaScript |
| CLI tool | bru |
newman |
inso |
| GraphQL support | Yes | Yes | Yes |
| WebSocket support | Partial | Yes | Yes |
| Price | Free | Free / $12+ per user | Free (OSS) |
Bruno's main limitation compared to Postman is ecosystem maturity. Postman has years of integrations, a larger plugin marketplace, and more polished collaboration features. Insomnia has a more refined UI for GraphQL introspection and design-mode OpenAPI editing.
But for teams that want API tests to live in Git, get reviewed like code, and run in CI without any external accounts, Bruno is the strongest option available. The scripting surface is solid, the CLI works well, and the file format is genuinely readable.
When to Choose Bruno
Bruno is the right choice if:
- You want API collections version-controlled in Git as readable files
- Your team has data-sensitivity concerns about cloud-synced API credentials
- You want a completely free tool with no account or subscription
- You work in a monorepo and want API tests to live next to the code they test
Stick with Postman if you rely heavily on Postman's mock servers, its built-in API monitoring, or the wider ecosystem of pre-built collections shared by third-party services. Postman's collection format is also what most API documentation sites export, so there's a network effect that still matters.
For most engineering teams doing serious API testing in 2026, Bruno's approach — Git-first, local-first, no account required — is not a compromise. It's the right architecture.