Web Development · Developer Tooling
Bruno: The API Client That Stores Requests in Git
Bruno keeps API collections as plain files in your repository. No account, no cloud sync, no vendor lock-in. Here's how it compares to Postman and why teams are switching.
Anurag Verma
6 min read
Sponsored
Every developer on your team has tested an API endpoint this week. The question is whether those requests live anywhere useful, or whether they exist in someone’s Postman workspace tied to a personal account and never committed to source control.
Bruno answers this differently. Collections are stored as plain .bru files, in a directory you commit to the repository alongside the code. When a new developer clones the repo, they have the full collection. When someone updates a request, that change shows up in the pull request diff. There’s no sync service, no account required, no API key to share.
What Bruno Is
Bruno is an open-source desktop API client built by Anoop M D. Functionally it covers the same ground as Postman or Insomnia: compose and send HTTP requests, organize them into collections, write pre-request scripts and tests, work with environments. The core difference is the storage model.
Where Postman stores collections in Postman’s cloud (or exports to a .json file you have to manually manage), Bruno writes every request to disk in a readable text format:
# collections/users/get-profile.bru
meta {
name: Get User Profile
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/users/{{userId}}
body: none
auth: bearer
}
headers {
Accept: application/json
}
auth:bearer {
token: {{authToken}}
}
tests {
test("status is 200", function() {
expect(res.status).to.equal(200);
});
test("response has user data", function() {
const data = res.getBody();
expect(data.id).to.not.be.undefined;
expect(data.email).to.not.be.undefined;
});
}
The .bru format is readable in any text editor and diffs cleanly. When you change a URL or add a header, git shows exactly what changed.
Setting Up a Collection
Install Bruno from the official site (available for macOS, Windows, and Linux). Open Bruno, create a new collection, and point it at a directory in your project. Convention varies. Most teams use bruno/ or .bruno/ at the project root.
Environment variables live in separate files that you can selectively gitignore:
# bruno/environments/local.bru
vars {
baseUrl: http://localhost:3000
authToken: dev-token-change-me
}
vars:secret [
authToken
]
The vars:secret directive marks fields as sensitive. Bruno won’t display them in the UI after entry, and the file should be in .gitignore for environments with real credentials. For shared dev environments with fake credentials, you commit the file. For production secrets, you don’t.
A typical setup:
my-project/
├── src/
├── bruno/
│ ├── .gitignore # ignores production.bru
│ ├── collection.bru # collection metadata
│ ├── environments/
│ │ ├── local.bru # committed (fake creds)
│ │ ├── staging.bru # committed (staging API keys)
│ │ └── production.bru # gitignored
│ ├── auth/
│ │ ├── login.bru
│ │ └── refresh-token.bru
│ ├── users/
│ │ ├── get-profile.bru
│ │ ├── update-profile.bru
│ │ └── delete-account.bru
│ └── orders/
│ ├── create-order.bru
│ └── list-orders.bru
Running Collections in CI
Bruno ships a CLI runner (@usebruno/cli) that runs collections headlessly. This makes it straightforward to run API tests in CI:
npm install -g @usebruno/cli
# Run a specific folder
bru run bruno/users --env staging
# Run the full collection
bru run bruno --env staging --reporter json
The JSON reporter writes results to a file. Wire it into your CI pipeline to fail builds when API tests break.
# .github/workflows/api-tests.yml
- name: Run Bruno API tests
run: |
npm install -g @usebruno/cli
bru run bruno --env staging --reporter json --output results.json
env:
BRUNO_STAGING_TOKEN: ${{ secrets.STAGING_API_TOKEN }}
Bruno reads environment variable overrides from the shell, so secrets from CI don’t have to be committed anywhere.
GraphQL and WebSocket Support
Bruno handles GraphQL queries without special configuration:
# collections/graphql/get-user.bru
meta {
name: Get User Query
type: graphql
seq: 1
}
post {
url: {{baseUrl}}/graphql
body: graphql
auth: bearer
}
body:graphql {
query {
user(id: "{{userId}}") {
id
name
email
orders {
id
total
}
}
}
}
body:graphql:vars {
{
"userId": "{{userId}}"
}
}
How It Compares to Postman
The meaningful differences come down to what you want from the tool:
| Bruno | Postman (Free) | Postman (Paid) | |
|---|---|---|---|
| Collection storage | Git files | Postman cloud | Postman cloud |
| Account required | No | Yes | Yes |
| CLI runner | Yes | Newman | Newman |
| Environments | Files | Cloud/exported | Cloud/exported |
| Team sharing | Via git | Via workspace | Via workspace |
| Pricing | Free/open source | Free with limits | $12+/user/month |
| GraphQL | Yes | Yes | Yes |
| Self-hosted | N/A | No | Enterprise only |
For Insomnia, the comparison is similar. Insomnia moved to requiring a cloud account for collections a couple of years back, which pushed a lot of teams toward alternatives.
The tradeoff is real: Postman has a better UI in some areas, better mock server support, and a richer ecosystem of integrations. If your team is already on Postman and getting value from it, switching costs are real and the marginal benefit of file-based storage may not justify the move.
But if you’re a team that cares about code review catching API changes, wants new developers to have working request collections the moment they clone the repo, and prefers not to depend on a SaaS product for local dev tooling, Bruno is worth evaluating.
Pre-Request Scripts and Variable Chaining
One pattern that’s genuinely useful for auth-heavy APIs is chaining: one request runs first, its response sets a variable, subsequent requests use that variable.
# auth/login.bru
meta {
name: Login
type: http
seq: 1
}
post {
url: {{baseUrl}}/api/auth/login
body: json
auth: none
}
body:json {
{
"email": "{{email}}",
"password": "{{password}}"
}
}
script:post-response {
const data = res.getBody();
bru.setVar("authToken", data.token);
bru.setVar("userId", data.user.id);
}
After the login request runs, authToken and userId are available in all subsequent requests in the same run. Run the collection in order (set seq on each request) and the auth flow works without manual copying of tokens.
The Practical Case
The value proposition is clearest for backend-heavy projects where the API surface changes frequently. When a developer changes an endpoint (adds a query parameter, renames a field, changes an auth requirement), the Bruno collection update is part of the same pull request as the code change. Reviewers see both. When someone asks “what does the staging environment authentication flow look like?”, the answer is in the repository.
That’s a simpler answer than “open Postman, switch to the shared workspace, hope whoever set it up shared it correctly.”
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored