Skip to content

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

Anurag Verma

6 min read

Bruno: The API Client That Stores Requests in Git

Sponsored

Share

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:

BrunoPostman (Free)Postman (Paid)
Collection storageGit filesPostman cloudPostman cloud
Account requiredNoYesYes
CLI runnerYesNewmanNewman
EnvironmentsFilesCloud/exportedCloud/exported
Team sharingVia gitVia workspaceVia workspace
PricingFree/open sourceFree with limits$12+/user/month
GraphQLYesYesYes
Self-hostedN/ANoEnterprise 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

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored