Migrating Postman to Bruno in a Go monorepo — a field guide

The ticket lands on a Monday morning: "Migrate Postman collections to Bruno, integrate them into the monorepo." You've used Postman for years without thinking too hard about it. Bruno you've heard of but never opened. The official docs list features. Medium articles explain "the 10 reasons to switch." Neither tells you concretely how to start clean on a monorepo with 6 services — patient API, lab, doctor portal, backoffice, billing, drug database — 3 environments, and secrets you can't commit.

This is that guide. No preamble, no progressive intro — straight to what you need to know so the result is clean from the first commit.

Why Bruno

The real reason isn't "Bruno is better than Postman." It's that Postman has drifted toward a cloud-first model that creates concrete problems in a team:

  • Postman collections live in Postman's cloud, not in your repo. They're not reviewable in a PR, they don't follow branches, and if someone modifies a request without telling anyone, it shows up nowhere in your version history.
  • Workspace sharing requires an account and a paid Postman organization plan once you go beyond 4 or 5 active developers.
  • The Postman client weighs 300+ MB (Electron). Anecdotal on its own, but representative of the model: grow the tool to justify the cloud value.
  • Postman environment variables are stored in Postman's cloud, which creates friction for secrets: either you put them there (and lose control) or you manage them outside (and you're maintaining two separate configurations).

Bruno solves all of this with one architectural decision: collections are text files (the .bru format, a simple DSL) stored in a directory. No account, no cloud, no magic sync. A git clone and everything is there. Request changes show up in diffs, PRs, and git blame. That's the only real reason to migrate.

The concepts in 5 minutes

Bruno has three concepts to understand before opening the interface. Two are similar to Postman, one is meaningfully different.

Collection — a directory of .bru files. When you open Bruno and create a collection, you pick a folder on your disk. That's it. Each request is a .bru file in that folder or a subfolder.

Environment — a named set of variables, defined in the Bruno UI and stored in the collection directory under an environments/ subfolder, one .bru file per environment. These files are version-controlled. They hold non-sensitive variables: base URLs, ports, domain names, feature flags. You can put base_url = http://localhost:8080 in there; you should not put a JWT token or a client certificate.

.env — a standard .env file at the root of the collection, gitignored. It holds secrets: tokens, passwords, paths to certificates. Bruno loads it automatically. In your .bru files, you access environment variables with {{TOKEN}} (which looks first in the active Environment, then in the .env) or with process.env.TOKEN in scripts.

Concept Version-controlled? Use for
environments/*.bru Yes base_url, ports, domain names
.env No (gitignored) Tokens, certificates, passwords
.bru (requests) Yes API call definitions

Structure in the monorepo

First decision: where to put the collections in the monorepo. Options include postman/ (legacy name, avoid it), api-collection/ (verbose), api/ (ambiguous if you already have Go packages called api), bruno/ (the tool name — explicit, short, unambiguous).

The bruno/ choice has an added benefit: it's visible in the tree that this is a Bruno collection, not a Go code directory. When someone clones the repo for the first time, they don't have to guess what's in that folder.

Recommended structure for 6 services:

bruno/
├── environments/
│   ├── local.bru
│   ├── dev.bru
│   └── prod.bru
├── .env              # gitignored — tokens, certs
├── .gitignore
├── patient-api/      # Patient API — REST
│   ├── health.bru
│   ├── patients/
│   │   ├── create-patient.bru
│   │   └── get-patient.bru
│   └── ...
├── lab-service/      # Lab Service — gRPC
│   ├── lab-results.bru
│   └── ...
├── doctor-portal/    # REST
│   └── ...
├── backoffice/       # REST
│   └── ...
├── billing-service/  # REST
│   └── ...
└── drug-db-api/      # REST — external API (e.g. OpenFDA)
    └── ...

The .gitignore inside bruno/ contains at minimum:

.env

One service = one subfolder. Inside, you can create as many subfolders as needed to organize requests by resource or feature. Bruno renders the directory tree as-is in its explorer.

Managing environments

Environment files are .bru files with a slightly different syntax from request files. Here's what a realistic local.bru looks like:

vars {
  patient_url: http://localhost:8080
  lab_url: localhost:9090
  doctor_url: http://localhost:8081
  backoffice_url: http://localhost:8082
  billing_url: http://localhost:8083
  drug_db_url: https://api.openfda.gov
  grpc_tls: false
}

vars:secret [
  AUTH_TOKEN
  DRUG_DB_API_KEY
]

The vars:secret block lists the names of secret variables — it declares their existence without their value. The actual values come from the .env. This is what allows Bruno to handle display (masked in the UI) without storing secrets in the versioned file.

The dev.bru file looks like this:

vars {
  patient_url: https://patient-dev.acme-internal.com
  lab_url: lab-dev.acme-internal.com:443
  doctor_url: https://doctor-dev.acme-internal.com
  backoffice_url: https://backoffice-dev.acme-internal.com
  billing_url: https://billing-dev.acme-internal.com
  drug_db_url: https://api.openfda.gov
  grpc_tls: true
}

vars:secret [
  AUTH_TOKEN
  DRUG_DB_API_KEY
]

To switch environments in Bruno: dropdown menu in the top right of the interface, select local, dev, or prod. All requests immediately use the new environment's variables.

Secrets in .env

The .env at the root of bruno/ holds the actual values of secret variables declared in the environments:

AUTH_TOKEN=
DRUG_DB_API_KEY=
CLIENT_CERT_PATH=/home/user/.certs/client.crt
CLIENT_KEY_PATH=/home/user/.certs/client.key

In a .bru request file, you reference these variables with double-brace syntax:

headers {
  Authorization: Bearer {{AUTH_TOKEN}}
  X-DrugDB-Key: {{DRUG_DB_API_KEY}}
}

Bruno resolves {{AUTH_TOKEN}} by looking in order: active Environment variables, then .env. If you've defined AUTH_TOKEN in both, the Environment wins. That's the expected behavior: you can override a secret per environment without touching the .env file.

For client certificates (mutual TLS), Bruno supports configuration in the collection settings. You can reference paths from the .env: {{CLIENT_CERT_PATH}} in the certificate path field.

gRPC with server reflection

Acme's lab service exposes a gRPC service — analysis results stream in as each machine finishes a measurement, without waiting for the full batch to complete. The good news: Bruno supports gRPC natively, and it supports server reflection mode — you don't need the .proto file to discover and call methods. The server responds to a reflection request and Bruno builds the list of available methods.

For reflection to work, your Go service needs to have registered the reflection service. If it hasn't yet, two lines to add in main.go:

import "google.golang.org/grpc/reflection"

// In the function that configures the gRPC server:
reflection.Register(grpcServer)

Here's what a .bru file for a unary gRPC request looks like:

meta {
  name: Get Lab Results
  type: grpc
  seq: 1
}

url: {{lab_url}}

body {
  {
    "patient_id": "{{patient_id}}",
    "test_type": "blood_panel",
    "date_from": "2026-01-01"
  }
}

grpc {
  method: LabResultService/GetResults
  reflect: true
  tls: {{grpc_tls}}
}

The reflect: true field tells Bruno to use server reflection to fetch the schema. tls: {{grpc_tls}} uses the environment variable — false locally, true on dev/prod. The URL is just host:port, no scheme. The patient_id in the body can be injected from a pre-request script or copied from a previous REST response (patient record creation).

For gRPC streaming (server-stream, client-stream, bidirectional), Bruno supports that too but the configuration differs slightly — the official docs cover that case better than any summary can.

What a real .bru file looks like

This is where people coming from Postman are most surprised: requests are plain text files with a simple syntax, not 200-line nested JSON. Here's a complete REST request with bearer auth, JSON body, and custom headers:

meta {
  name: Create Patient Record
  type: http
  seq: 1
}

post {
  url: {{patient_url}}/v1/patients
  body: json
  auth: bearer
}

auth:bearer {
  token: {{AUTH_TOKEN}}
}

headers {
  X-Request-ID: {{$randomUUID}}
  X-Client-Version: 2.1.0
}

body:json {
  {
    "last_name": "Dupont",
    "first_name": "Marie",
    "dob": "1985-03-12",
    "nss": "{{$randomUUID}}"
  }
}

tests {
  test("status is 201", function() {
    expect(res.status).to.equal(201);
  });

  test("has patient id", function() {
    expect(res.body.patient_id).to.be.a("string");
  });
}

A few things worth noting about this format:

  • seq controls the display order in Bruno's explorer.
  • {{$randomUUID}} is a built-in dynamic variable — Bruno provides several ($timestamp, $randomInt, etc.).
  • The tests block uses Chai for assertions — same syntax as Postman if you were using it before v10.
  • There's no pre-request script in some weird JSON/DSL format — it's plain JavaScript in a script:pre-request { } block.

This file is readable in a diff. If someone changes the body or adds a header, you see it in the PR. That's the fundamental difference from Postman.

Migrating from Postman

Bruno supports import from Postman Collection v2.1.

What migrates cleanly: simple REST requests, headers, JSON/form-data bodies, non-secret environment variables. Bruno generates one .bru file per request, folder structure is preserved.

What doesn't migrate correctly:

  • Complex pre-request scripts — Postman scripts using pm.sendRequest() or advanced environment manipulation need to be rewritten manually. Bruno's syntax is similar but not identical.
  • Postman Flows — completely Postman-specific, no equivalent in Bruno.
  • Monitors and mock servers — Postman cloud features, out of scope for Bruno.
  • Secret variables — you normally don't export these, so they won't appear in the exported JSON anyway.

Pragmatic recommendation: import one collection, check the result, fix what's broken. Don't bulk-import all 6 services without verifying. Critical requests (auth, resource creation) are worth recreating by hand in 5 minutes rather than importing and leaving with unpredictable behavior. The .bru format is simple enough for that.

Step by step

  1. Download and install Bruno — available at usebruno.com/downloads for macOS, Linux, and Windows. No account required, no cloud, standard installation.
  2. Export from Postman — in Postman, right-click on the collection → ExportCollection v2.1 → save the JSON file locally.
  3. Create the Bruno collection in the repo — in Bruno: File → Open Collection → select the bruno/ folder in the monorepo (or create a new folder if it doesn't exist yet). Bruno opens that folder as the collection root.
  4. Import the Postman collection — in Bruno: File → Import Collection → Postman Collection v2.1 → select the JSON exported in step 2. Bruno creates one .bru file per request in the current folder, preserving the folder structure.
  5. Create the environments — in Bruno: Environments menu (gear icon, top right) → Create Environment → name it local. Add your variables: base_url, ports, etc. Repeat for dev and prod. Bruno creates the corresponding files in bruno/environments/.
  6. Create the .env and .gitignore — create bruno/.env with your secrets (tokens, API keys, certificate paths). Create bruno/.gitignore with at minimum .env in it. Commit the .gitignore, never commit the .env.
  7. Verify and fix — test each imported request one by one. Fix variables if needed ({{variable}} syntax from Postman is compatible with Bruno, but complex pre-request scripts need to be rewritten manually in a script:pre-request { } block using plain JavaScript).
  8. Commitgit add bruno/git commit -m "feat: migrate API collections from Postman to Bruno". The .bru files, environments/*.bru, and .gitignore are version-controlled. The .env never is.

Conclusion

The Postman → Bruno migration takes half a day for a monorepo of this size. Half of that time is initial setup (structure, environments, .env). The other half is verifying that imported requests actually work.

What you gain isn't "a better API tool." It's that collections become code: reviewable in PRs, diffable commit by commit, consistent across all developers without manual sync. When someone adds an endpoint or changes a parameter, it's in the same commit as the Go code.

That's enough to justify the migration.

Comments (0)