# How to publish and tag pacts in a Pact Broker

> Run the broker on Docker and Postgres, publish each pact under its commit and branch, and record deployments so the broker knows what runs where.

Source: https://voxgig.com/howto/publish-and-tag-pacts-in-a-broker

- Audience: platform-team
- Level: intermediate
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Run the Pact Broker image beside Postgres with `PACT_BROKER_DATABASE_URL` and `PACT_BROKER_BASE_URL` set, and give consumer CI the read-write credentials. Publish with `pact-broker publish pacts --consumer-app-version "$GITHUB_SHA" --branch "$GITHUB_REF_NAME"`, so every build is a distinct version and the branch travels beside it. After each deploy, run `record-deployment` for that version and environment. The broker then answers which commit runs where, and refuses a changed contract under a reused version.

---
## You will need

A CI runner that can run Docker, a Postgres the broker can own, the Pact Broker client CLI in
the consumer pipeline, and one pact file from a consumer test. Verified 2026-10-21 against Node
22.22.0, Ruby 3.3.6, `pact_broker` 2.122.0, `pact_broker-client` 1.78.0, `padrino-core` 0.17.0,
`puma` 6.6.1, and `sqlite3` 2.10.0. The sample runs the broker as the Ruby gem the Docker image
wraps, over SQLite, because the check that re-runs this page has no Docker daemon. The compose
file is committed and read, not run.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Pact Broker (self-hosted)](https://docs.pact.io/pact_broker/docker_images/pactfoundation) | Several teams, a Postgres you already run, and a platform team willing to own one more service | A container, a database, backups, TLS, and basic auth to operate, plus the upgrades the image publishes | You want single sign-on, per-team permissions, or bi-directional contracts without running anything |
| [pact files committed to a shared git repository](https://docs.pact.io/getting_started/sharing_pacts) | Two teams, one provider, and a pact that changes rarely | Every change is a pull request someone must merge in time, and the repository records what was merged rather than what each environment runs | A third consumer appears, or anyone asks what is deployed where |
| [PactFlow](https://support.smartbear.com/pactflow/docs/en/user-guide.html) | Teams, users, API tokens, and bi-directional contracts managed for you, driven by the same client CLI | A paid hosted service, your contracts on someone else's infrastructure, and a vendor's roadmap | Contracts cannot leave your network, or the self-hosted broker already covers what you need |

A git repository works while the number of pacts stays small, because every change is a pull
request and the merge is the handshake. PactFlow removes the operating cost and takes the
contracts with it, onto a hosted service with its own
[users](https://support.smartbear.com/pactflow/docs/en/user-guide/settings/users.html) and
[teams](https://support.smartbear.com/pactflow/docs/en/user-guide/settings/teams.html). The
self-hosted broker sits between: one container, one database, and the CLI PactFlow also uses.

## Run the broker against Postgres, with SQLite for the spike

The broker is one container reading two settings: where its database is, and the URL clients
reach it at. The compose file pins the image and gives it a Postgres of its own.

```yaml title="compose.yaml"
  pact-broker:
    image: pactfoundation/pact-broker:3.0.0-pactbroker2.121.2
    depends_on:
      postgres:
        condition: service_healthy
    ports:
      - "9292:9292"
    environment:
      PACT_BROKER_DATABASE_URL: postgres://pact:CHANGE_ME@postgres/pact_broker
      # The host the broker is reached at. Links in every response are built from it.
      PACT_BROKER_BASE_URL: https://pact.internal.example.com
      # Two basic auth users: one for the pipelines that publish, one that can only read.
      PACT_BROKER_BASIC_AUTH_USERNAME: ci
      PACT_BROKER_BASIC_AUTH_PASSWORD: CHANGE_ME
      PACT_BROKER_BASIC_AUTH_READ_ONLY_USERNAME: reader
      PACT_BROKER_BASIC_AUTH_READ_ONLY_PASSWORD: CHANGE_ME
      PACT_BROKER_ALLOW_PUBLIC_READ: "false"
```

`docker compose up -d` starts both, and the broker migrates its own schema on first start. The
[image documentation](https://docs.pact.io/pact_broker/docker_images/pactfoundation) describes the
two basic auth users: read-write credentials for CI, read-only credentials for developers'
machines. Every setting is an environment variable named after the entry on the
[settings page](https://docs.pact.io/pact_broker/configuration/settings) with a `PACT_BROKER_`
prefix, which is where `PACT_BROKER_ALLOW_PUBLIC_READ` comes from.

The same page allows `sqlite` for investigations and spikes, which is how this page runs it. The image wraps a Ruby application, and the sample starts that application directly
with a Rack file, so the gate can run it with no Docker daemon.

```ruby title="config.ru"
app = PactBroker::App.new do |config|
  config.database_connection = Sequel.connect(ENV.fetch("PACT_BROKER_DATABASE_URL"), encoding: "utf8")
  config.base_url = ENV["PACT_BROKER_BASE_URL"]
  # A fresh database is otherwise seeded with an example consumer, provider, and
  # deployments, which would show up beside your own.
  config.seed_example_data = false
end
```

Left at its default, a fresh broker carries an example consumer, an example provider, environments
named `test` and `production`, and a recorded deployment of each.[^1]

## Publish under the commit and the branch

The consumer pipeline publishes after its own tests have written the pact. Two values go with
the file: the version, which is the commit, and the branch, which is a property of that version
rather than part of its name.

```yaml title=".github/workflows/publish.yml"
      - run: gem install pact_broker-client --version 1.78.0
      # The version is the commit, so every build on every branch is a distinct version and
      # the broker can say exactly which commit a pact, a verification, or a deployment
      # belongs to. The branch is recorded beside it, not as the version.
      - run: >
          pact-broker publish pacts
          --consumer-app-version "$GITHUB_SHA"
          --branch "$GITHUB_REF_NAME"
          --build-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
```

The broker's [versioning guidance](https://docs.pact.io/getting_started/versioning_in_the_pact_broker)
asks for exactly this: a version that is, or contains, the commit, and is unique.[^2] The
[client CLI](https://docs.pact.io/pact_broker/client_cli/readme) reads the broker URL and the basic
auth user from `PACT_BROKER_BASE_URL`, `PACT_BROKER_USERNAME`, and `PACT_BROKER_PASSWORD`, so the
workflow sets those once as job environment variables. `PACT_BROKER_TOKEN` is PactFlow's bearer
token, which the self-hosted broker refuses with a 400. `SCARF_ANALYTICS=false` beside them stops
pact-js reporting the consumer's install to Scarf. A `--tag` flag still exists, and the
[tags page](https://docs.pact.io/pact_broker/tags) still states its rule: tag with the branch
name on publish and the environment name on deploy. The same page says that
[branches](https://docs.pact.io/pact_broker/branches) and environments have superseded both
uses.[^3] A pipeline built from scratch uses `--branch` and `record-deployment`, and adds `--tag`
only for a provider whose Pact library still selects pacts by tag.

## Record the deployment, so the broker knows what runs where

A pact in the broker says what a consumer version needs. Which consumer version is in production
is a separate fact, and the broker only knows it if the deploy pipeline tells it. The
[recording page](https://docs.pact.io/pact_broker/recording_deployments_and_releases) explains the
two verbs: `record-deployment` for an application that replaces its previous version in an
environment, and `record-release` for one that does not, such as a mobile app. The demo runs the
whole sequence against a broker started on a free port.

```bash
node demo.mjs
```

```text output
== publish, commit 3f9c2d1 on main
Created billing-web version 3f9c2d1 with branch main
Pact successfully published for billing-web version 3f9c2d1 and provider billing-api.
  View the published pact at http://127.0.0.1:46861/pacts/provider/billing-api/consumer/billing-web/version/3f9c2d1

== create the production environment
Created production environment in the Pact Broker with UUID 24876d7d-911e-47b6-bb71-5c3adce83900

== record the deployment
Recorded deployment of billing-web version 3f9c2d1 to production environment in the Pact Broker.

== publish, commit 8b1e4f0 on main
Created billing-web version 8b1e4f0 with branch main
Pact successfully published for billing-web version 8b1e4f0 and provider billing-api.
  View the published pact at http://127.0.0.1:46861/pacts/provider/billing-api/consumer/billing-web/version/8b1e4f0

== what the broker knows
  version 8b1e4f0  branches: main
  version 3f9c2d1  branches: main
  production runs 3f9c2d1
```

The last three lines come from the broker's API, not the CLI. Two versions sit on
the `main` branch, and production runs the older one, because the second commit has been
published but not deployed. The helper reads two HAL resources: `/environments` lists the production
environment, and its `currently-deployed` resource lists the versions recorded against
it.

```js title="broker.mjs"
export async function currentlyDeployed(baseUrl, pacticipant) {
  const { _embedded } = await hal(baseUrl, '/environments')
  const rows = []
  for (const environment of _embedded.environments) {
    const deployed = await hal(baseUrl, `/environments/${environment.uuid}/deployed-versions/currently-deployed`)
    for (const d of deployed._embedded.deployedVersions) {
      if (d._embedded.pacticipant.name === pacticipant) {
        rows.push({ environment: environment.name, version: d._embedded.version.number })
      }
    }
  }
  return rows
}
```

`pacticipant` is the broker's word for either party to a pact.[^4] The deploy job in the committed
workflow runs `record-deployment` after the deploy step, with the same `$GITHUB_SHA` the publish
job used, which is the only reason the two records line up.

## Check it worked

Six tests run against two brokers.

```bash
node --test
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 12347.975922
```

One publishes under the commit and reads the branch back from the version resource. One publishes
the same content under the same version twice, which is accepted, then a changed contract under
that version, which is refused. One records two deployments in turn and checks that the second
replaces the first. One reads `compose.yaml` and checks the image, the Postgres URL, and the
base URL are there. The last runs each workflow job's credentials against a second broker with
basic auth on. The summary line to compare against is `# pass 6`.

## When it goes wrong

The deploy record says `main` is in production, but three commits have shipped from `main`
since Monday. The pipeline published with `--consumer-app-version main`, so the branch name is
the version, every build on the branch is the same version, and a deployment record of `main`
names no commit. The pitfall script runs the same two builds that way.

```bash
node pitfall.mjs
```

```text output
== build 1 publishes as version main
Created billing-web version main

== deploy, recorded as version main
Recorded deployment of billing-web version main to production environment in the Pact Broker.

== which commit is live
  production runs main

== build 2 changes the contract and publishes as version main again, exit 1
Cannot change the content of the pact for billing-web version main and provider billing-api, as race conditions will cause unreliable results for can-i-deploy. Each pact must be published with a unique consumer version number. Some Pact libraries generate random data when a concrete value for a type matcher is not specified, and this can cause the contract to mutate - ensure you have given example values for all type matchers. For more information see https://docs.pact.io/go/versioning
@@ -15,6 +15,7 @@
         "body": [
           {
             "amount": 450,
+            "currency": "EUR",
             "customer": "Globex",
             "id": "in_1002"
           }
```

The first build is accepted. The second, which adds a field the consumer now reads, exits 1 with
a diff of the two contracts, because a version holds one contract and `main` already has one.
Change the pipeline to publish under `$GITHUB_SHA` with `--branch main`, as the workflow does,
and both builds become distinct versions on one branch.

Every link the broker returns points at the wrong host, `http://pact-broker:9292` inside the
network instead of the address the pipeline used. `PACT_BROKER_BASE_URL` is unset, so the broker
builds links from the request it received. Set it to the public URL, which the
[image documentation](https://docs.pact.io/pact_broker/docker_images/pactfoundation) describes as
strongly recommended in production because an unset base URL also exposes a cache poisoning
weakness.

The broker that worked on a laptop answers 500 under two pipelines at once. It is running on
SQLite, which the image documentation supports for investigations and spikes only, because it does
not support concurrent requests. Point `PACT_BROKER_DATABASE_URL` at Postgres, as the compose file does.

## When not to do this

Do not stand up a broker for one consumer and one provider who sit in the same team. A pact file
in the provider repository, updated by pull request, is the whole workflow until a second
consumer or a second environment arrives.

Do not record a deployment before the deploy has succeeded. The record is the fact the broker
reasons from, and a record written at the start of a failed deploy leaves production described as
running a version it never received.

Do not build a new pipeline on tags, which the broker's own documentation marks as superseded by
branches and environments.

Do not self-host to save money if nobody owns the service. The broker needs a database, backups,
TLS, credentials, and image upgrades, and a broker nobody patches is an unauthenticated list of
your internal APIs. PactFlow exists for the team that wants the workflow without the operations.

Do not keep SQLite past the spike. The database is disposed of with the container unless it is
mounted, and the broker needs concurrent writers the moment two pipelines publish at once.

## Related how-tos

- [Choose consumer-driven or spec-driven contract tests](/howto/choose-consumer-driven-or-spec-driven-contract-tests)

- [Contract test an API you do not own](/howto/contract-test-an-api-you-do-not-own)

## Last verified

Verified 2026-10-21 against Node 22.22.0, Ruby 3.3.6, `pact_broker` 2.122.0, `pact_broker-client`
1.78.0, `padrino-core` 0.17.0, `puma` 6.6.1, and `sqlite3` 2.10.0. Every output block is what the
command preceding it printed, run in the page's code directory after `npm ci`, which installs the
gems through Bundler. The broker ran as the Ruby gem over SQLite; the Docker image, Postgres, and
the GitHub Actions workflow were read from their documentation and not run.

[^1]: The default is in the broker's own
[runtime configuration](https://github.com/pact-foundation/pact_broker/blob/master/lib/pact_broker/config/runtime_configuration.rb),
where `seed_example_data` is set to `true`. On an empty database the broker creates two
applications, Example App and Example API, the environments `test` and `production`, and a
deployment of each into both. The first answer to the question this page is about is therefore
an invented one. The gem's [README](https://github.com/pact-foundation/pact_broker) describes the
same first visit for its example directory, a pact between the Zoo App and the Animal Service.

[^2]: The [versioning page](https://docs.pact.io/getting_started/versioning_in_the_pact_broker)
opens with a one-line summary: the version used to publish pacts and verification results should
be, or contain, the commit, to avoid race conditions. The broker enforces the argument. The refusal in
the pitfall gives the same two reasons, race conditions and `can-i-deploy`, and ends with a link
back to the page that made them.

[^3]: The [tags page](https://docs.pact.io/pact_broker/tags) states a Golden Rule for tagging: tag
with the branch name when you publish and with the environment name when you deploy. Above it
sits a tip saying that tags representing branches and environments have been superseded by
first-class support for both. The rule outlives its own retirement as the migration path for
clients that have not caught up.

[^4]: Consumer and provider applications are known as `pacticipants`, which the
[broker overview](https://docs.pact.io/pact_broker/overview) calls a bad pun the Pact Broker
author now regrets. The word is in every CLI flag, `--pacticipant`, and every URL,
`/pacticipants/billing-web`, so the regret has stayed in the prose.