
Introduction 🎯
There’s an unspoken tension in every API development team: Postman works great for individuals, but collaboration is a mess.
You export a collection as JSON, commit it to the repo, and the next pull request shows a diff of 2000 lines because Postman reordered your headers. Someone changes an endpoint on their machine but forgets to export. The “canonical” collection lives on a cloud workspace that half the team can’t access.
Bruno eliminates that tension entirely. It’s an open-source API client built around a simple idea: API collections are plain-text files stored on your filesystem, versioned with Git, and reviewable in pull requests. No cloud account required. No proprietary binary format. No export step.
# A Bruno collection is a folder with .bru files — that's it
tree api-collection/
api-collection/
├── bruno.json # Collection metadata
├── users/
│ ├── get-users.bru # Plain-text request definition
│ ├── create-user.bru
│ └── delete-user.bru
└── environments/
└── local.bru
And the CLI counterpart — bruno-cli — runs those same collections in CI/CD with test reports, data-driven iteration, and JUnit output.
If you’ve ever wished Postman collections could be as easy to review as source code, this article is for you. We’ll cover the .bru format, the bru CLI, scripting, assertions, CI/CD integration, and a head-to-head comparison with Postman/Newman and Hurl. Let’s build. 🚀
Part 1: What Makes Bruno Different?
1.1 The Manifesto
Bruno’s manifesto is worth reading — it’s refreshingly direct:
Bruno is a closed source API client that is also open source… wait, what?
Bruno is an open-source API client. It does not store your API collections in the cloud. Your data stays on your computer, under your control.
Key principles:
| Principle | Bruno | Postman |
|---|---|---|
| Data storage | Local filesystem (.bru files) | Cloud workspace |
| Account required | ❌ Never | ✅ Required for sync |
| Collection format | Plain text, human-readable | Proprietary JSON |
| Git diff friendly | ✅ Yes — each request is a separate file | ❌ Single JSON blob |
| Offline-first | ✅ Full functionality offline | ⚠️ Limited without account |
| Open source | ✅ MIT license | ❌ Closed source |
| Free forever | ✅ No paid tiers for core features | ⚠️ Paid tiers for team features |
1.2 Plain-Text Collections vs JSON Blobs
This is the core distinction. Compare how Postman and Bruno store a simple GET request:
Postman (excerpt from a collection JSON):
{
"info": {
"name": "My API",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Get Users",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
}
],
"url": {
"raw": "https://api.example.com/users?page=1",
"protocol": "https",
"host": ["api", "example", "com"],
"path": ["users"],
"query": [
{ "key": "page", "value": "1" }
]
}
}
}
]
}
Bruno (.bru file — one file per request):
get {
url: https://api.example.com/users?page=1
}
headers {
Authorization: Bearer {{token}}
}
That’s it. A .bru file is a simple DSL with labelled sections (get, headers, body, tests, script:pre-request, etc.). Each request gets its own file. Git shows exactly what changed — a new header, a modified URL — not a reformatted JSON blob.
Part 2: The .bru File Format
2.1 Anatomy of a .bru File
A .bru file uses labelled sections to describe an HTTP request and its associated scripts:
# File: users/get-users.bru
get {
url: https://api.example.com/users
}
headers {
Authorization: Bearer {{auth_token}}
Accept: application/json
}
query {
page: 1
limit: 20
}
Available section types:
| Section | Purpose |
|---|---|
get / post / put / patch / delete / head / options / patch | HTTP method + URL |
headers | Request headers (key: value) |
query | URL query parameters (key: value) |
body | Request body (JSON, text, XML, form-data, etc.) |
params | Path parameters |
auth | Auth type and configuration |
script:pre-request | JavaScript to run before the request |
script:post-response | JavaScript to run after the response |
tests | Declarative assertions |
docs | Markdown documentation for the request |
vars | Request-level variables |
assert | Declarative assertion rules (GUI-based) |
2.2 Request Examples
GET with query params:
get {
url: https://api.github.com/users/usebruno
}
headers {
Accept: application/vnd.github.v3+json
}
POST with JSON body:
post {
url: https://api.example.com/auth/login
}
body:json {
{
"email": "user@example.com",
"password": "securepass123"
}
}
headers {
Content-Type: application/json
}
POST with form-data:
post {
url: https://api.sms.net.bd/sendsms
}
body:form-urlencoded {
api_key: {{sms_api_key}}
msg: Your OTP is 123456
to: 8801800000000
}
headers {
Content-Type: application/x-www-form-urlencoded
}
PUT with path params:
put {
url: https://api.example.com/users/{{user_id}}
}
params {
user_id: 42
}
body:json {
{
"name": "Updated Name",
"email": "updated@example.com"
}
}
2.3 Adding Tests and Assertions
Tests live in the tests section using standard Chai-style assertions:
post {
url: https://api.example.com/auth/login
}
body:json {
{
"email": "user@example.com",
"password": "securepass123"
}
}
tests {
test("should return 200", function() {
expect(res.status).to.equal(200);
});
test("should return auth token", function() {
expect(res.body.token).to.be.a('string');
expect(res.body.token).to.not.be.empty;
});
test("should have refresh token", function() {
expect(res.body.refreshToken).to.be.a('string');
});
}
2.4 Declarative Assertions (No-Code)
For simpler cases, Bruno also supports declarative assertions via the GUI, stored in the .bru file as assert sections:
assert {
res.status: eq 200
res.body.status: eq success
res.body.id: isNotEmpty
res.headers['content-type']: contains application/json
res.responseTime: lt 1000
}
Available operators: eq, neq, gt, gte, lt, lte, contains, matches, startsWith, endsWith, isEmpty, isNotEmpty, isNull, isTruthy, isFalsy, isNumber, isString, isBoolean, isArray, isJson.
Part 3: Bruno CLI (bruno-cli)
The CLI is where Bruno shines for automation. You write the collection in the desktop app (or by hand), commit it to Git, and bru run in CI/CD with zero adjustments.
3.1 Installation
# Global install via npm
npm install -g @usebruno/cli
# Verify
bru --version
# → 3.4.2
Or via Docker:
docker run --rm -v $(pwd):/collection usebruno/bruno-cli:latest run
3.2 Running a Collection
# Run the entire collection (from the collection directory)
cd collections/my-api
bru run
# Run with an environment
bru run --env local
# Run a single request file
bru run login.bru
# Run a folder
bru run users/
# Run multiple files
bru run login.bru users/create-user.bru
3.3 Output
01-get-users (200 OK) - 234 ms
02-create-user (201 Created) - 412 ms
03-get-user-by-id (200 OK) - 156 ms
04-delete-user (204 No Content) - 89 ms
Tests
✓ get-users: should return list of users
✓ create-user: should create user with valid data
✓ delete-user: should return 204
📊 Execution Summary
┌───────────────┬──────────────────────────┐
│ Metric │ Result │
├───────────────┼──────────────────────────┤
│ Status │ ✓ PASS │
│ Requests │ 4 (4 Passed, 0 Failed) │
│ Tests │ 3/3 │
│ Assertions │ 12/12 │
└───────────────┴──────────────────────────┘
3.4 Key CLI Options
# Environment & variables
bru run --env staging # Use a named environment
bru run --env-var baseURL=https://staging.example.com # Override at runtime
bru run --env-file ./environments/ci.bru # Explicit env file path
# Filtering
bru run --tests-only # Only requests with tests/assertions
bru run --tags=smoke,sanity # Filter by tag (include)
bru run --exclude-tags=slow,draft # Filter by tag (exclude)
bru run --bail # Stop on first failure
# Parallel execution
bru run --parallel # Run requests in parallel
# Data-driven testing
bru run --csv-file-path ./test-data.csv # Iterate over CSV rows
bru run --json-file-path ./data.json # Iterate over JSON array
bru run --iteration-count 3 # Repeat N times
# Reporting
bru run --reporter-html report.html # HTML report
bru run --reporter-json results.json # JSON report
bru run --reporter-junit results.xml # JUnit XML (for CI)
# Security
bru run --sandbox=developer # Enable developer mode (v3+)
bru run --insecure # Skip TLS verification
bru run --delay 500 # Add delay between requests
3.5 Docker Integration
# Pull the CLI image
docker pull usebruno/bruno-cli:latest
# Run a collection from Docker
docker run --rm \
-v $(pwd):/collection \
usebruno/bruno-cli:latest \
run --env ci --reporter-junit /collection/results.xml
Part 4: Scripting & Request Chaining
4.1 Pre-Request Scripts
Execute JavaScript before a request to set up dynamic data, compute signatures, or fetch tokens:
post {
url: https://api.example.com/data
}
script:pre-request {
// Generate a timestamp-based signature
const timestamp = Date.now();
const signature = CryptoJS.HmacSHA256(
`data${timestamp}`,
bru.getVar("apiSecret")
).toString();
bru.setVar("timestamp", timestamp);
bru.setVar("signature", signature);
}
headers {
X-Timestamp: {{timestamp}}
X-Signature: {{signature}}
}
4.2 Post-Response Scripts
Capture data from one response to use in subsequent requests — the foundation of request chaining:
post {
url: https://api.example.com/auth/login
}
body:json {
{
"email": "{{email}}",
"password": "{{password}}"
}
}
script:post-response {
// Save the auth token for downstream requests
bru.setVar("authToken", res.body.token);
bru.setVar("refreshToken", res.body.refreshToken);
bru.setVar("userId", res.body.user.id);
}
4.3 Full Chaining Example
# 1. login.bru — authenticate and capture token
post {
url: https://api.example.com/auth/login
}
body:json {
{ "email": "{{email}}", "password": "{{password}}" }
}
script:post-response {
bru.setVar("accessToken", res.body.token);
bru.setVar("userId", res.body.user.id);
}
tests {
test("login successful", function() {
expect(res.status).to.equal(200);
});
}
# 2. create-post.bru — use captured token + userId
post {
url: https://api.example.com/posts
}
headers {
Authorization: Bearer {{accessToken}}
}
body:json {
{
"title": "My Post",
"content": "Hello from Bruno!",
"authorId": "{{userId}}"
}
}
script:post-response {
bru.setVar("postId", res.body.id);
}
tests {
test("post created", function() {
expect(res.status).to.equal(201);
expect(res.body.id).to.be.a('string');
});
}
# 3. verify-post.bru — confirm the post exists
get {
url: https://api.example.com/posts/{{postId}}
}
headers {
Authorization: Bearer {{accessToken}}
}
tests {
test("post exists and authored by correct user", function() {
expect(res.status).to.equal(200);
expect(res.body.authorId).to.equal(bru.getVar("userId"));
});
}
Run them all: bru run login.bru create-post.bru verify-post.bru
4.4 Available Scripting APIs
| API | Purpose |
|---|---|
bru.getVar("name") | Read a variable |
bru.setVar("name", value) | Write a variable |
bru.getEnvVar("name") | Read an environment variable |
bru.setEnvVar("name", value) | Write an environment variable |
req / res | Request / response objects |
console.log() | Debug output (visible in Timeline) |
CryptoJS | Built-in cryptographic library |
axios | Built-in HTTP client (for nested calls) |
chai | Built-in assertion library |
prompt | Prompt user for input (GUI only) |
Part 5: Bruno vs Postman/Newman
This is the comparison most readers care about. Let’s be honest about where each tool excels.
5.1 Format & Collaboration
| Aspect | Bruno | Postman + Newman |
|---|---|---|
| Collection format | Plain-text .bru files (one per request) | Proprietary JSON (whole collection in one file) |
| Git diff | ✅ Clean, per-file diffs | ❌ Massive single-file diffs |
| Code review | ✅ PRs show exact request changes | ⚠️ Near impossible to review |
| Offline | ✅ Fully offline, always | ⚠️ Cloud workspace required for sync |
| Export step | ❌ Never needed — files are on disk | ✅ Required before commit |
5.2 Feature Comparison
| Feature | Bruno (Desktop + CLI) | Postman (Desktop) | Newman (CLI) | Hurl |
|---|---|---|---|---|
| Pricing | ✅ Free & open source | ⚠️ Free tier, paid teams | ✅ Free | ✅ Free & open source |
| Desktop app | ✅ Native (Electron) | ✅ Native (Electron) | ❌ CLI only | ❌ CLI only |
| Collection format | .bru (plain text) | JSON (proprietary) | JSON | .hurl (plain text) |
| One file per request | ✅ Yes | ❌ Single blob | ❌ Single blob | ✅ Yes (can split) |
| Git-native | ✅ Designed for Git | ❌ Postman sync | ❌ Postman sync | ✅ Designed for Git |
| GraphQL | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
| gRPC | ✅ Yes | ✅ Yes | ❌ | ❌ |
| WebSocket | ✅ Yes | ✅ Yes | ❌ | ❌ |
| SOAP | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
| Pre-request scripts | ✅ JS sandbox | ✅ JS sandbox | ✅ JS sandbox | ❌ No scripting |
| Post-response scripts | ✅ JS sandbox | ✅ JS sandbox | ✅ JS sandbox | ❌ No scripting |
| Request chaining | ✅ bru.setVar() | ✅ pm.variables.set() | ✅ Same as Postman | ✅ Captures |
| Declarative assertions | ✅ GUI + assert section | ✅ GUI + pm.test() | ✅ Same as Postman | ✅ Built-in DSL |
| Data-driven testing | ✅ CSV/JSON files | ✅ CSV/JSON files | ✅ CSV/JSON files | ❌ Manual |
| CI/CD reports | ✅ HTML, JSON, JUnit | ⚠️ Newman only | ✅ HTML, JSON, JUnit | ✅ HTML, JSON, JUnit |
| Parallel execution | ✅ --parallel | ⚠️ Collection Runner | ✅ Parallel | ✅ --parallel |
| Docker support | ✅ Official image | ❌ | ✅ Official image | ✅ Official image |
| OpenAPI import | ✅ bru import openapi | ✅ Import | ❌ | ❌ |
| Cloud account | ❌ Not required | ✅ Required for sync | ❌ Not required | ❌ Not required |
| Environment variables | ✅ .bru env files | ✅ JSON env files | ✅ JSON env files | ✅ --variable |
| Proxy support | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
| VS Code extension | ✅ Bruno VS Code ext | ❌ | ❌ | ❌ |
| Secret management | ✅ Dotenv + secret providers | ✅ Postman Secrets | ✅ Same | ❌ |
| OAuth 2.0 flow | ✅ Built-in | ✅ Built-in | ❌ | ❌ |
| AI/agent integration | ✅ Claude, Codex, Cursor, Copilot | ✅ Postman AI | ❌ | ❌ |
5.3 When to Use What
Choose Bruno when:
- You want API collections versioned in Git with meaningful diffs
- You value offline-first operation with no cloud dependency
- You need both a desktop app and a CI/CD CLI with identical behaviour
- Your team reviews API changes in pull requests
- You want multiple protocol support (REST, GraphQL, gRPC, WebSocket, SOAP)
Choose Postman/Newman when:
- Your team is already deeply invested in Postman workspaces and sync
- You need Postman’s advanced monitoring and public API network
- You rely on Postman’s built-in mock servers and documentation hosting
- You need team workspaces with role-based access control
Choose Hurl when:
- You want the fastest possible CLI-only API testing tool
- You don’t need a desktop app at all
- Your tests are simple request → assert chains
- You want a single binary with no npm/Node.js dependency
Part 6: Migration from Postman
Bruno provides built-in tools to migrate from Postman:
# Import a Postman collection (via the desktop app)
# File → Import → Select Postman Collection
# Or import via CLI
bru import postman --source ./postman-collection.json --output ./my-api
The Scripts Translator helps convert Postman’s pm.* API to Bruno’s bru.* API:
| Postman | Bruno |
|---|---|
pm.variables.get("key") | bru.getVar("key") |
pm.variables.set("key", val) | bru.setVar("key", val) |
pm.environment.get("key") | bru.getEnvVar("key") |
pm.environment.set("key", val) | bru.setEnvVar("key", val) |
pm.response.code | res.status |
pm.response.json() | res.body |
pm.expect().to.equal() | expect().to.equal() (Chai) |
Part 7: CI/CD Integration
7.1 GitHub Actions
Bruno has an official GitHub Action:
# .github/workflows/api-tests.yml
name: API Tests
on:
push:
branches: [main]
pull_request:
jobs:
bruno:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: usebruno/bruno-cli-action@v1
with:
working-directory: collections/my-api
command: >
run
--env ci
--reporter-html ../../reports/bruno-report.html
--reporter-junit ../../reports/bruno-results.xml
- name: Upload test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: bruno-report
path: reports/bruno-report.html
The action exposes outputs:
- name: Bruno API Tests
id: bruno
uses: usebruno/bruno-cli-action@v1
with:
working-directory: collections/my-api
command: 'run --env ci'
- name: Check results
run: |
echo "Passed: ${{ steps.bruno.outputs.passed }}"
echo "Failed: ${{ steps.bruno.outputs.failed }}"
echo "Total: ${{ steps.bruno.outputs.total }}"
7.2 Manual CI/CD (No Action)
steps:
- uses: actions/checkout@v4
- name: Install Bruno CLI
run: npm install -g @usebruno/cli
- name: Run API tests
run: |
cd collections/my-api
bru run --env ci \
--reporter-html ../../reports/report.html \
--reporter-junit ../../reports/results.xml
7.3 GitLab CI
api-tests:
image: node:20
before_script:
- npm install -g @usebruno/cli
script:
- cd collections/my-api
- bru run --env ci --reporter-junit results.xml
artifacts:
reports:
junit: collections/my-api/results.xml
7.4 Jenkins
stage('API Tests') {
agent {
docker { image 'usebruno/bruno-cli:latest' }
}
steps {
sh 'cd collections/my-api && bru run --env ci --reporter-junit results.xml'
}
post {
always {
junit 'collections/my-api/results.xml'
}
}
}
7.5 Docker in CI
# GitLab CI with Docker executor
api-tests:
image: usebruno/bruno-cli:latest
script:
- cd collections/my-api
- bru run --env ci --reporter-junit results.xml
Part 8: Environments & Variables
8.1 Environment Files
Bruno stores environments as .bru files in an environments/ directory:
# environments/local.bru
vars {
baseURL: http://localhost:3000
authToken: test-token-local
}
# environments/ci.bru
vars {
baseURL: https://staging-api.example.com
authToken: ""
}
8.2 Variable Precedence (lowest to highest)
- Collection-level defaults (in
bruno.json) - Environment variables (
.bruenv files) - Folder-level variables
- Request-level
varssection - Script-set variables (
bru.setVar()) - CLI
--env-varoverrides
8.3 Dynamic Variables
Built-in dynamic variables:
| Variable | Description | Example Output |
|---|---|---|
{{$guid}} | UUID v4 | 550e8400-e29b-41d4-a716-446655440000 |
{{$timestamp}} | Unix timestamp in ms | 1721462400000 |
{{$isoTimestamp}} | ISO 8601 timestamp | 2026-07-20T12:00:00.000Z |
{{$randomInt}} | Random integer | 42 |
Part 9: The OpenCollection YAML Format
Bruno now offers a second, YAML-based collection format called OpenCollection alongside the classic .bru format. It’s an open specification using standard YAML — same Git-friendly benefits, broader tooling compatibility:
# opencollection.yml
version: "1.0"
name: My API Collection
requests:
- method: GET
url: https://api.example.com/users
headers:
Authorization: Bearer {{token}}
tests:
- expression: res.status
operator: equals
value: 200
- expression: res.body
operator: isNotEmpty
The OpenCollection YAML specification is an open standard, meaning other tools can read and write it too.
To import an OpenAPI spec as OpenCollection YAML:
bru import openapi \
--source https://petstore3.swagger.io/api/v3/openapi.json \
--output ./petstore-api \
--collection-format opencollection
Part 10: Bruno vs Hurl — A Nuanced Comparison
Since we covered Hurl in a previous article, it’s worth a specific comparison:
10.1 Philosophy
| Dimension | Bruno | Hurl |
|---|---|---|
| Approach | Desktop app + CLI | CLI only |
| Language | JavaScript (scripts), DSL (requests) | Plain-text DSL |
| Learning curve | Low (GUI) + Medium (CLI) | Low (simple cases) |
| Use case | Full API development lifecycle | Testing & automation only |
| Dependencies | Node.js (CLI), Electron (desktop) | Single Rust binary |
10.2 When Bruno Wins
- You need a GUI for exploration during development
- You need scripts (pre-request, post-response) for complex workflows
- You need multiple protocols (gRPC, WebSocket, GraphQL)
- You want a unified tool from development → testing → CI/CD
10.3 When Hurl Wins
- You want the lightest possible CI dependency (no Node.js)
- Your tests are straightforward request → assert chains
- You prefer Hurl’s built-in assertion DSL over JavaScript
- You need the fastest execution (Rust vs Node.js)
10.4 Side-by-Side: Same Test in Bruno vs Hurl
Bruno (login.bru):
post {
url: https://api.example.com/auth/login
}
body:json {
{ "email": "user@example.com", "password": "pass" }
}
script:post-response {
bru.setVar("token", res.body.token);
}
tests {
test("status is 200", function() { expect(res.status).to.equal(200); });
test("has token", function() { expect(res.body.token).to.be.a('string'); });
}
Hurl (login.hurl):
POST https://api.example.com/auth/login
{ "email": "user@example.com", "password": "pass" }
HTTP 200
[Asserts]
jsonpath "$.token" exists
[Captures]
token: jsonpath "$.token"
Both are valid approaches. Bruno gives you JavaScript power; Hurl gives you zero-dependency simplicity.
Part 11: Bruno in a Real Workflow
Let’s put it all together with a realistic workflow:
Project Structure
project-root/
├── collections/
│ └── my-api/
│ ├── bruno.json # Collection manifest
│ ├── auth/
│ │ ├── login.bru
│ │ └── refresh-token.bru
│ ├── users/
│ │ ├── create-user.bru
│ │ ├── get-users.bru
│ │ ├── get-user.bru
│ │ └── delete-user.bru
│ ├── payments/
│ │ ├── create-payment.bru
│ │ ├── execute-payment.bru
│ │ └── refund.bru
│ └── environments/
│ ├── local.bru
│ ├── ci.bru
│ └── staging.bru
└── .github/
└── workflows/
└── api-tests.yml
Developer Workflow
- Develop: Open the collection in Bruno Desktop, explore endpoints, write tests
- Commit:
git add collections/my-api/ && git commit -m "add payment endpoints" - Review: PR shows per-file diffs — teammates review the
.brufile changes - CI:
bru run --env ciexecutes the collection on every push - Debug: Failed tests produce JUnit reports viewable in CI artifacts
Commands Cheat Sheet
| Task | Command |
|---|---|
| Run all tests | bru run |
| Run with environment | bru run --env local |
| Run a specific folder | bru run payments/ |
| Run a single request | bru run auth/login.bru |
| Run with test-only filter | bru run --tests-only |
| Generate HTML report | bru run --reporter-html report.html |
| Save JUnit for CI | bru run --reporter-junit results.xml |
| Override a variable | bru run --env-var baseURL=https://staging.example.com |
| Run with CSV data | bru run --csv-file-path data.csv |
| Run in parallel | bru run --parallel |
| Import OpenAPI spec | bru import openapi --source ./spec.yaml --output ./collection |
Part 12: Best Practices
12.1 Organise Collections
- One file per request — Bruno’s superpower is per-file diffs
- One folder per resource —
users/,payments/,auth/ - Name files clearly —
create-user.bru, notreq1.bru - Keep environments separate — never hardcode a URL or API key in a request
12.2 Writing Tests
- Test status codes first —
expect(res.status).to.equal(200) - Test response shape — not just “it didn’t error”
- Use
script:pre-requestfor dynamic setup, not for logic that belongs in tests - Use
script:post-responsefor chaining, not for assertions - Keep assertions declarative when possible (the
assertsection)
12.3 CLI in CI
- Always use
--reporter-junitfor CI tool integration - Use
--bailin pre-merge checks to fail fast; omit it in full test suites - Use
--tagsto separate smoke tests (--tags=smoke) from full regression - Use
--env-varfor secrets — never commit real credentials to env files - Pin your CLI version:
npm install -g @usebruno/cli@3.4.2
12.4 Git Workflow
- Review
.brufiles in PRs — each file is small enough for meaningful review - Use GitHub PR comments on specific lines of request definitions
- Don’t commit generated OpenAPI imports without reviewing them
- Use
.gitignorefor local environment overrides if needed
Conclusion
Bruno solves a problem that Postman has ignored for years: API collections should be as easy to version, review, and collaborate on as source code.
By storing each request as a plain-text .bru file on your filesystem, Bruno makes Git the collaboration layer — not a proprietary cloud workspace. Your team reviews API changes in PRs alongside code changes. Your CI pipeline runs the same collections without any export step, cloud account, or API key.
Why adopt Bruno:
- Git-native — per-file diffs, meaningful PR reviews, no export step
- Open source & free — MIT license, no paid tiers, no account required
- Offline-first — your collections are on your disk, always
- Desktop + CLI — one format for development and automation
- Multiple protocols — REST, GraphQL, gRPC, WebSocket, SOAP, all in one tool
- Scriptable — pre-request and post-response JavaScript with full API
- CI-ready — JUnit reports, Docker image, GitHub Action, data-driven testing
Is it a complete Postman replacement? For many teams — yes. If your primary need is designing, testing, and automating API calls with Git-based collaboration, Bruno does everything Postman does and does the collaboration part better. The only gaps are Postman’s cloud-hosted features (monitors, public API network, team workspaces) — and if you don’t need those, Bruno is the better choice.
Try it: install the desktop app from usebruno.com, clone a collection from bruno-collections, and experience what API development looks like when your tools respect your filesystem. 🚀