Bruno: The Open-Source Postman Alternative That Stores Collections in Git

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 bruno

macOS/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 bruno

The 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/cli

Creating 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.json

bruno.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 code
  • res.headers — response headers
  • res.getBody() — parsed response body
  • res.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 variable
  • bru.setEnvVar("name", "value") — set an environment variable
  • bru.getEnvVar("name") — read an environment variable
  • bru.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 staging

This 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">local

Run a single request:

bru run auth/login.bru --env staging

Export results as JUnit XML (for CI test reporters):

bru run --env staging --reporter-junit results.xml

GitHub 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-junit

The 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:

  1. Developer adds a new endpoint to the API
  2. Developer creates or modifies a .bru file for that endpoint
  3. The .bru file is committed alongside the API code in the same PR
  4. CI runs bru run --env staging against the PR's deployed preview environment
  5. Reviewer can see exactly what changed in the API test by reading the .bru file 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 .bru request files
  • Environment .bru files (with secrets listed in vars:secret, not with actual secret values)
  • bruno.json

What to gitignore:

  • .env file at collection root (contains actual secret values)
  • bru.json if 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.

Read more