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.
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.
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" 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 practice | Effect | When to use |
|---|---|---|
| Fast-forward-only promotion | Makes divergence impossible | Every environment branch |
merge-base --is-ancestor check | Detects divergence early | Daily, in CI |
| Artefact re-tagging | Deploys what was tested | Always; never rebuild per environment |
| Deployment tags | Auditable record of what ran | Every deployment |
| Branch protection on environments | Only the pipeline may push | Every environment branch |
| Revert-and-promote rollback | Keeps history truthful | Preferred over pointer moves |
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Environment branch refuses to fast-forward | Something was committed directly to it | Merge it into the default branch, then reset |
| Production behaves differently from staging | Artefacts were rebuilt per environment | Promote by digest, not by rebuilding |
| Nobody can say what is deployed | No deployment record | Tag every deployment |
| Hotfix disappeared at the next release | Fix never reached the default branch | Always merge to main first |
| Rollback lost the previous state | Force-push with no tag recorded | Tag before moving any pointer |
| Divergence discovered weeks late | No scheduled ancestry check | Run 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.
Related Jump to heading
- Branch-per-Environment GitOps Patterns β where the model works well and where it does not.
- Promoting a Release From Staging to Production β the promotion step in detail.
- Hotfix Branches That Do Not Drift From Main β the emergency path that preserves the invariant.
- Running a Scheduled Release Train β cutting releases on a cadence rather than on demand.
- Rolling Back a Deployment With Git β reverting versus moving the pointer.