Environment & Deployment Branches Jump to heading

Using branches to represent environments is one of the most contested patterns in Git workflow design, and both sides are partly right. Done carelessly, a staging branch and a production branch accumulate divergence until nobody can say what is actually deployed, and every release becomes an archaeology exercise. Done deliberately β€” as pointers that only ever fast-forward, with the artefact rather than the branch as the unit of promotion β€” they give you an auditable record of what ran where. This part of Git Workflow Architecture & Branching Strategies covers the difference.

Prerequisites Jump to heading

The Failure Mode Worth Naming First Jump to heading

The pattern collapses when changes are made on an environment branch rather than promoted to it. A hotfix applied directly to production exists nowhere else; the next promotion from staging either loses it or conflicts with it, and after three such incidents the branches have genuinely diverged. Everything below is a way of preventing changes from originating anywhere except the default branch.

Promotion against divergenceIn a promotion model every commit originates on the default branch and environment branches only ever fast-forward to commits that already exist there. In a divergence model changes are made directly on environment branches, and the branches accumulate content that exists nowhere else.Changes made on the branchPromotion onlywhere commits originateseveral branchesthe default branchcan you diff environments?meaningless after weeksexact and usefulhotfix pathdirect, then lostthrough main, then promotedrollbackreconstruct by handmove a pointer backthe right-hand column is a rule about where commits are created, not about how many branches exist

Step 1 β€” Make Environment Branches Fast-Forward Only Jump to heading

If the branch can only fast-forward, it cannot diverge. That is the whole mechanism, and it is enforceable server-side.

# Locally, refuse to create a merge on an environment branch
git config branch.production.mergeoptions '--ff-only'

# Promote by fast-forwarding to a commit that already exists on main
git switch production
git merge --ff-only "$(git rev-parse origin/staging)"
git push origin production
# Verification: production must always be an ancestor-or-equal of main
git merge-base --is-ancestor origin/production origin/main \
  && echo "production contains nothing main does not"

That single check, run in CI, is the strongest guarantee available: if it ever fails, something was committed directly to an environment branch and needs attention before the next release.

Step 2 β€” Promote Artefacts, Not Source Jump to heading

A branch pointer says which commit is intended to be deployed. What actually runs is an artefact, and rebuilding it per environment reintroduces the variance the whole exercise was meant to remove.

# Build once, tag by commit
docker build -t registry.example.com/app:"$(git rev-parse --short HEAD)" .
docker push registry.example.com/app:"$(git rev-parse --short HEAD)"

# Promote by re-tagging the same digest β€” never by rebuilding
digest=$(docker inspect --format='{{index .RepoDigests 0}}' registry.example.com/app:"$(git rev-parse --short HEAD)")
docker tag "$digest" registry.example.com/app:production
# Verification: the production tag and the commit tag share a digest
docker inspect --format='{{.Id}}' registry.example.com/app:production
docker inspect --format='{{.Id}}' registry.example.com/app:"$(git rev-parse --short origin/production)"

The provenance side of this β€” proving that an artefact came from a specific commit β€” is covered in linking a container image to its commit.

One build, three environmentsA commit on the default branch produces one artefact. Each environment branch is fast-forwarded to that commit in turn, and the deployment re-tags the same artefact rather than rebuilding it, so what runs in production is byte-identical to what was tested.Commit on mainreviewed, mergedOne buildtagged by commit idFast-forward stagingsame artefactFast-forward productionsame artefact againrebuilding per environment is how a difference nobody chose reaches production

Step 3 β€” Define the Hotfix Path in Advance Jump to heading

The pattern breaks under pressure, so the emergency path has to be decided when nobody is under any.

# Branch from the deployed commit, not from main
git switch -c hotfix/PAY-931 origin/production

# Fix, then merge into main FIRST
git switch main && git merge --no-ff hotfix/PAY-931
git push origin main

# Only then fast-forward the environment branches
git switch production && git merge --ff-only main
# Verification: after the hotfix, the invariant still holds
git merge-base --is-ancestor origin/production origin/main && echo "no divergence"

Branching from the deployed commit keeps the fix minimal; merging into the default branch before promoting keeps the invariant. The detail is in hotfix branches that do not drift from main.

Step 4 β€” Record What Is Deployed Where Jump to heading

A branch pointer is a fact about intent. Deployment records are facts about reality, and they should be queryable without asking a pipeline.

# A lightweight, append-only record using Git itself
git tag -a "deploy/production/$(date -u +%Y%m%dT%H%M%SZ)" -m 'Deployed by the release pipeline' origin/production
git push origin --tags
# What was deployed to production over the last month?
git tag -l 'deploy/production/*' --sort=-creatordate --format='%(creatordate:short) %(objectname:short) %(subject)' | head
# Verification: the current deployment tag matches the branch
git rev-parse "$(git tag -l 'deploy/production/*' --sort=-creatordate | head -1)^{commit}"
git rev-parse origin/production

Step 5 β€” Keep Rollback Honest Jump to heading

Rolling back is moving a pointer backwards, which is a force-push β€” and force-pushing an environment branch destroys the record of what was deployed unless something else holds it.

SAFETY WARNING β€” never force-push an environment branch as a rollback without first recording where it pointed. The deployment tags in Step 4 are what make the operation reversible; without them the previous state exists only in someone’s terminal scrollback. Prefer rolling forward with a revert on the default branch, and reserve the pointer move for incidents where minutes matter.

# Preferred: roll forward
git switch main && git revert --no-edit <bad-commit> && git push origin main
git switch production && git merge --ff-only main

# Emergency: move the pointer, having recorded where it was
git tag -a "rollback/from/$(git rev-parse --short origin/production)" -m 'Pre-rollback state' origin/production
git push origin --tags
git push --force-with-lease origin "$(git rev-parse origin/production~1):production"
What promotion looks like in the commit graphEvery commit is created on the default branch. The staging and production refs are pointers into that same line of history, moved forward by fast-forward only, so the graph has a single shape and the environments are positions along it.environments are positions on one historymainABCDEstagingDproductionCproduction trails staging trails main β€” and every commit exists in exactly one place

Operating the Model Without It Decaying Jump to heading

Three habits keep a promotion model working after the initial setup, and all three are cheap.

The first is running the ancestry check on a schedule rather than only at promotion time. A branch that has diverged does so at a specific moment β€” usually an incident β€” and the sooner that is visible the smaller the reconciliation. A daily job that asserts each environment branch is an ancestor of the default branch costs nothing and catches the problem while it is one commit deep.

The second is treating environment branch protection as seriously as the default branch. If anyone can push to production, someone eventually will, and the model’s guarantee evaporates without any announcement. Protection on an environment branch should allow only fast-forward pushes from the release pipeline’s identity, with human pushes refused entirely β€” the ruleset design is covered in designing branch protection rulesets for an org.

The third is keeping the number of environments honest. Each environment branch adds a promotion step, a protection rule, a set of deployment tags and a place for divergence to hide. Teams frequently carry a branch for an environment that has not been deployed to in a year, and removing it is pure gain. The test is simple: if nobody can say what is running in an environment without checking, it is not part of the release path any more.

Finally, be clear about what this model does not do. It does not make deployments safe, it does not replace testing, and it does not tell you whether a release is good β€” it tells you exactly what was intended to run where, and makes divergence detectable. That is a narrow guarantee, and its value comes from being reliable rather than broad. Teams that expect more from it end up adding logic to the branch structure, which is where the pattern earns its bad reputation.

Configuration Reference Jump to heading

Setting or practiceEffectWhen to use
Fast-forward-only promotionMakes divergence impossibleEvery environment branch
merge-base --is-ancestor checkDetects divergence earlyDaily, in CI
Artefact re-taggingDeploys what was testedAlways; never rebuild per environment
Deployment tagsAuditable record of what ranEvery deployment
Branch protection on environmentsOnly the pipeline may pushEvery environment branch
Revert-and-promote rollbackKeeps history truthfulPreferred over pointer moves

Troubleshooting Jump to heading

SymptomLikely causeFix
Environment branch refuses to fast-forwardSomething was committed directly to itMerge it into the default branch, then reset
Production behaves differently from stagingArtefacts were rebuilt per environmentPromote by digest, not by rebuilding
Nobody can say what is deployedNo deployment recordTag every deployment
Hotfix disappeared at the next releaseFix never reached the default branchAlways merge to main first
Rollback lost the previous stateForce-push with no tag recordedTag before moving any pointer
Divergence discovered weeks lateNo scheduled ancestry checkRun it daily

Frequently Asked Questions Jump to heading

Is branch-per-environment an anti-pattern? Jump to heading

It is an anti-pattern when branches are where changes are made, and a reasonable pattern when they are pointers that only move forward. The criticism is aimed at the first version, which is the one most teams arrive at accidentally. If you enforce fast-forward-only promotion, most of the objections stop applying.

Should we use tags instead of branches? Jump to heading

Tags work well and are arguably cleaner, since a tag moving is more obviously an event than a branch moving. The trade is tooling: many deployment systems watch branches and not tags. Either is defensible; what matters is that the reference only ever moves forward through commits that already exist on the default branch.

How does this fit with trunk-based development? Jump to heading

Naturally β€” trunk-based development says where commits are created, and this says how they reach environments. The two are complementary, and the combination is the most common shape in teams deploying several times a day. The trunk side is covered in trunk-based development setup.

What about environments that need different configuration? Jump to heading

Configuration belongs outside the artefact, not on a branch. The moment an environment branch carries a configuration difference, it has diverged and the ancestry check will fail β€” correctly. Inject configuration at deployment time and keep the code identical across environments.

How many environments justify their own branch? Jump to heading

Fewer than most teams have. Each environment branch adds a promotion step, a protection rule, a set of deployment records and a place for divergence to hide, and the value comes entirely from being able to say what is running where. An environment that is deployed to continuously from the default branch does not need a reference of its own β€” the default branch already is that reference. An environment that receives a curated subset does.

A useful test is to ask who would notice if a given environment branch stopped being updated. If the answer is nobody for a week, the environment is not on the release path any more and the branch is bookkeeping for a process that has already stopped. Removing it is pure gain: one fewer promotion, one fewer place for a hotfix to be applied and forgotten, and one fewer entry in the ancestry check. Teams accumulate these branches during reorganisations and rarely remove them, so an annual review is usually worth more than any amount of tuning the promotion pipeline.