The provider team verifies your contract against a three-week-old copy of the pact file committed in their repository. Your consumer test changed a field name on Tuesday, their verification kept running against the old copy, and production broke on Monday. Two repositories hold two different contracts, so neither team can say which one is current.
What you get
You will end up with a broker from one compose file, a consumer pipeline publishing every pact under its commit and branch, and a deployment record naming the commit each environment runs. This is for you if teams exchange pacts by hand.
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) | 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 | 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 | 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 and teams. 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.
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 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 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.
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.
- 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
asks for exactly this: a version that is, or contains, the commit, and is unique.2 The
client CLI 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 still states its rule: tag with the branch
name on publish and the environment name on deploy. The same page says that
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 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.
node demo.mjs
== 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.
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.
node --test
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.
node pitfall.mjs
== 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 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
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.
Footnotes
-
The default is in the broker’s own runtime configuration, where
seed_example_datais set totrue. On an empty database the broker creates two applications, Example App and Example API, the environmentstestandproduction, 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 describes the same first visit for its example directory, a pact between the Zoo App and the Animal Service. ↩︎ Back to text -
The versioning page 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. ↩︎ Back to text -
The tags page 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. ↩︎ Back to text
-
Consumer and provider applications are known as
pacticipants, which the 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. ↩︎ Back to text