A field guide to branch-per-environment CI/CD
The delivery model where merge is the only deploy verb — feature branches run Dev, main runs QA, a release branch runs Prod, and Terraform makes it true on every merge.
Most teams run two systems that disagree about what is deployed.
One is version control, which knows what the code says. The other is the deployment apparatus next to it — pipeline definitions, promotion buttons, a dashboard that remembers what went where. The two drift apart the first week: an environment gets a hand-applied fix, a pipeline deploys an older artifact, a config value lives only in the tool. From then on, "what is running in QA?" is a question with two answers, and every incident starts with reconciling them.
There is a simpler contract, and it is the one we ran for the University of Wisconsin's enterprise data platform: the branch is the environment. Feature branches deploy to Dev. Main deploys to QA. A release branch deploys to Prod. Merge is the only deploy verb, Terraform makes the environment match the branch on every merge, and config fixes discovered in a higher environment are merged back down so nothing tested is ever lost. The platform this delivered was not small — fifty-plus Lambda functions and twenty-plus Step Functions workflows moved through this exact loop — and the whole apparatus is a git repository and one command. This brief is the model, the four disciplines that keep it honest, and the team size at which you should graduate out of it.
Three branch tiers. Three environments. One verb.
This is the counterpart to our selective-promotion brief, and the honest sibling: that model earns its complexity when dozens of engineers collide on one trunk. A platform team of a handful of engineers does not need it — it needs this one, run without exceptions.
Merge is the only deploy verb
No promote button, no deploy dashboard, no kubectl-by-hand. If it is not a merge, it did not happen — which means git history is the deployment history.
The environment is a projection
Dev, QA, and Prod are not places you push to; they are what a branch tier looks like when Terraform finishes. State cannot drift from code, because state is derived from code.
Fixes flow back down
A config change made while hardening QA or stabilizing Prod is real work. It merges back down the ladder — to the feature, to main — or the next release re-fights the same fire.
Four moves that keep the model honest.
The model is simple to describe and easy to corrupt. Every failure mode is some version of "we made an exception." These four moves are what running it without exceptions actually means.
Bind each branch tier to exactly one environment.
Feature branches deploy to Dev on every push. Main deploys to QA on every merge. The release branch deploys to Prod on create or merge. One tier, one environment, no sharing and no skipping — a feature cannot "just go straight to QA," because the only way into QA is a merge to main. The bind is what makes the mental model effortless: to know what is running anywhere, read the branch. To change what is running, change the branch. The entire deployment topology fits in one sentence, which is exactly the property that survives team turnover.
Make Terraform apply the merge's consequence.
On every merge, the automation does the same dumb, reliable thing: check out the branch, run terraform apply. Application code and infrastructure live in the same repository, so the merge that changes a Lambda's code is the merge that changes its memory limit — one review, one apply, one history. This is what made the model carry a real platform: fifty-plus functions and twenty-plus workflows are far too many to deploy by attention span. The apply-on-merge contract means the platform's entire operational surface is reproducible from any commit, and "roll back" is git revert followed by the same apply as any other change.
Fix config where it breaks, then merge it down.
QA is where environment truth emerges — the memory limit that was fine in Dev, the timeout that only matters at QA data volume. Make the fix as a commit on main, watch the apply, iterate to green. Same at Prod: config changes committed on the release branch, applied, verified. Then the non-negotiable half: merge the QA fix back to the open feature branches, and the Prod fix back to main. This backflow is the model's least glamorous and most load-bearing move — skip it, and the next feature branch reverts the fix you spent Tuesday finding, and the next release re-fights Prod's config from scratch.
Automate the housekeeping, not just the deploy.
The loop has chores, and chores get skipped unless they run themselves. After each feature merge: apply main to QA, then cut the next feature branch from the new tip — so work always starts from current truth, never from a stale base. After each main-to-release merge: check out the release, apply to Prod. None of this is clever, and that is the point — the cadence runs on rails, and the humans spend their attention on the one place the model wants judgment: what goes into the next merge.
The ladder, drawn once.
The whole model on one timeline. Commits climb the ladder by merge — feature to main to release — and each rung deploys to its environment on arrival. Config fixes made while hardening a rung flow back down in gold. Read it left to right and you have read the team's entire delivery process.
Notice what is absent: no deploy dashboard, no promotion queue, no environment spreadsheet. The diagram is the git history, and the git history is the deployment record — one artifact doing both jobs is the entire economy of the model.
What the model demands in return.
Branch-per-environment is cheap to run and unforgiving to cheat. Three demands it makes, and what happens when a team stops paying them:
Main must stay green, because main is QA.
Binding main to a live environment raises the price of a careless merge — the broken thing is not a build badge, it is the QA environment every open feature depends on. The mitigations are ordinary but mandatory: small merges, plan-before-apply as the pull-request gate, and an unhesitating git revert reflex — the revert redeploys the last good state through the same pipeline as everything else, usually in minutes. Teams that let main sit broken "while we fix forward" have quietly abandoned the model; the environment and the branch have started disagreeing again.
The backflow merges are work, and they are not optional.
Every QA config commit on main and every Prod config commit on the release branch is a small debt owed downstream. Pay it immediately — merge back to features, back to main — or watch it compound: features rebased on stale config, releases that surprise Prod with settings QA already learned the hard way. The failure is silent, which is what makes it dangerous. Instrument it: alert on environment-config commits that have not merged down within a day, and treat that alert with the seriousness of a failing test.
The model has a team-size ceiling — respect it.
One main bound to one QA means everyone's work integrates in one place on one cadence. For a platform team — two to eight engineers shipping a data platform — that constraint is the feature. For dozens of engineers across multiple teams, it becomes the bottleneck: features queue behind each other's QA cycles and release timing turns political. That is not a reason to complicate this model; it is the signal to graduate to a different one — the shared-sandbox, selective-promotion model we run for large engineering organizations. Knowing which side of that line you are on is most of the decision.
A model simple enough for agents to run.
Because every state change is a merge and every merge leaves the same trail — a diff, a plan, an apply log — this model is unusually legible to agents. The judgment stays human; the vigilance does not have to be:
Plan review before the merge
The terraform plan is the deployment's contract, and it is machine-readable. An agent reviews it on every pull request — flagging the resource replacement hiding in a rename, the security-group change nobody mentioned in the description, the destroy that should be a create-before-destroy — so the human reviewer reads a verdict, not four hundred lines of plan output.
Apply-failure triage
A failed apply on main blocks the QA rung for everyone, so time-to-diagnosis is the metric that matters. An agent subscribed to apply logs classifies the failure — transient AWS error worth a retry, a real dependency-ordering bug, a quota ceiling — and posts the diagnosis with the recommended fix before an engineer has opened the logs. We have run LLM-based pipeline-failure triage in production; apply logs are friendlier input than most.
The backflow, automated
The model's most-skipped chore is its best agent job: when a config commit lands on main or the release branch, an agent opens the merge-down pull requests to the affected feature branches immediately, with the context attached — what broke in QA, what the fix was. The debt from ledger item 02 gets collected while it is still small.
Release notes from the ladder
Because everything climbs by merge, the release branch's history is a complete, ordered manifest of what shipped. An agent turns each main-to-release merge into release notes and updated runbook pages as a matter of course — documentation as a by-product of the model rather than a task competing with the next feature.
The recurring theme across our briefs holds here too: agents amplify disciplined systems and flail in improvised ones. A deploy model with one verb and one history is the easiest possible substrate to hand them — one more argument for keeping the model boring.
Get the next one by email.
A short note from Robin when a new brief publishes — the point, and the link. No cadence promises, no funnel, one-click unsubscribe.
You'll get one confirmation email first — nothing sends until you click it.
If "what is running in QA?" has two answers —
If your environments drift from your repository, if deploys happen in a tool nobody fully trusts, or if your platform team is running large-org release machinery it does not need, the delivery model is the fixable constraint. An hour is usually enough to know which model fits your team — this one, or its larger sibling.