Application branching models are about merge conflicts. Infrastructure branching is about time.
An application branch that sits for two weeks acquires conflicts you resolve. A Terraform branch that sits for two weeks acquires something you cannot resolve: a plan computed against a world that has since changed. The reviewed evidence expires, and nothing tells you it has.
That single difference reshapes which branching models work here.
What makes infrastructure different
Section titled “What makes infrastructure different”Four properties that application repositories do not share.
A plan is evidence with an expiry date. It describes what would happen given the state and the real world at the moment it ran. Another apply, an autoscaling event, or a manual console change invalidates it silently.
Merged is not applied. In an application repository, merging to the default branch usually triggers a deployment. In infrastructure, merge and apply are often separated by an approval — and the window between them is a period where the repository says one thing and reality says another.
Conflicts are semantically dangerous. Two branches editing the same resource block produce a conflict a human resolves. The resolution is a configuration that neither reviewer approved and neither plan was computed against.
Concurrency is serialised by state locking. Terraform will not let two applies run against the same state, so long-lived parallel branches queue at apply time regardless of how parallel the development felt. Parallelism in an infrastructure repository is therefore mostly an illusion: the work overlaps, the applies do not, and the second one plans against a world the first has already changed.
The models
Section titled “The models”| Model | Fits infrastructure? | Why |
|---|---|---|
| Trunk-based, short branches | Yes — the default | Plans stay fresh, gap stays small |
| GitHub Flow | Yes | Effectively the same for this purpose |
| Release branches | Occasionally | Only for genuinely versioned artifacts |
| Environment branches | No | Covered at length in environments |
| Git Flow | No | Adds long-lived branches that solve nothing here |
Trunk-based with short-lived branches
Section titled “Trunk-based with short-lived branches”The default, and for the same reason it is the default for applications with the additional force of plan staleness.
One default branch, protected. main describes what infrastructure should be.
Short-lived feature branches. Hours to a couple of days. One concern each.
Plan on the pull request, apply after merge.
Apply promptly. The merge-to-apply gap is the risk window, and it is the one part of this model that depends on operational habit rather than on a repository setting.
Why the shortness matters more here:
Plans stay fresh. A branch open for four hours has a plan computed against roughly the current world.
Conflicts stay rare. Two engineers touching the same resource within a day is unusual; within three weeks it is likely.
Merged-but-unapplied stays small. Every merged change waiting to apply is a difference between the repository and reality — drift you created deliberately.
Review stays possible. A branch containing one change produces a plan a reviewer can hold in their head. A branch containing a week of work produces a plan that gets scrolled past, which converts the whole workflow into diff-only review while still looking rigorous.
Why not Git Flow
Section titled “Why not Git Flow”Git Flow — develop, feature/*, release/*, hotfix/*, main — was designed for software with versioned releases shipped to users who upgrade on their own schedule. Infrastructure has none of those properties.
There is no released version. Infrastructure is a single running system continuously modified, not an artifact somebody downloads.
develop has no meaning. In an application, develop is where changes accumulate before a release. In infrastructure, a change that has not been applied is not “waiting for release” — it is a divergence between the repository and reality that grows the longer it sits.
Release branches have nothing to stabilise. A release branch exists so a version can be hardened while development continues. Infrastructure has one version: the running one.
Hotfix branches are the normal path. In Git Flow a hotfix is exceptional, branching from main and merging to two places. In infrastructure, an urgent change is an urgent change — it branches from main, gets reviewed quickly, and applies. There is nowhere else to merge it to.
Every long-lived branch multiplies stale plans. Git Flow’s structure is long-lived branches. Infrastructure’s central problem is that branches go stale. The model adds precisely the thing that hurts.
If your infrastructure repository uses Git Flow, it is almost certainly because the organisation standardised on it for applications. That is a reasonable thing to have happened and a poor fit for this repository.
Where release branches do fit
Section titled “Where release branches do fit”One legitimate case, and it is not an environment.
A module repository publishes versioned artifacts that consumers pin. That genuinely has releases, and it can genuinely need to support more than one at a time.
main → v3.x developmentrelease/2.x → maintenance for consumers still on v2When a security fix must reach consumers who cannot yet take the v3 breaking changes, a maintenance branch on release/2.x producing v2.8.1 is exactly right.
The distinction from environment branches: a release branch tracks a published version with a defined support window. An environment branch tracks a running system, which is not a version and has no end date. The first has a lifecycle; the second accumulates divergence forever.
Keep the number small — one or two supported majors — and publish their support windows, or you are maintaining branches nobody asked for.
The merge-to-apply gap
Section titled “The merge-to-apply gap”The interval this whole model exists to minimise.
merge ──────── gap ──────── applyDuring it, main says one thing and infrastructure is another. That is drift you created, and it has specific costs:
A plan on top of it is confusing. The next pull request’s plan includes the unapplied change, so a reviewer sees changes the pull request did not make.
Rollback is ambiguous. Reverting a merged-but-unapplied commit means the revert applies nothing, which is fine — as long as everybody knows it was never applied. Usually nobody does.
Incident response is harder. “What is running?” is answered by the repository plus a list of what has not applied yet, and the second list rarely exists.
Reducing it:
Apply automatically for low-risk environments. Dev applies on merge. The gap is minutes.
Apply promptly for production, even when approval is manual. An approval that waits until Thursday is a four-day gap.
Make the gap visible. A check comparing the last applied commit to the head of the default branch, reported somewhere people look. Most teams have no idea how large this gap routinely is, and the first time they measure it the number is larger than anybody guessed — often several days, occasionally weeks for environments nobody applies to often.
Do not merge what you are not ready to apply. A pull request merged on Friday to “get it in” and applied on Monday is three days of a repository that describes something that does not exist.
Handling urgent changes
Section titled “Handling urgent changes”Infrastructure has genuine emergencies, and a branching model that ignores them gets bypassed.
The normal path, compressed. Branch, change, plan, get one reviewer, merge, apply. Under real time pressure this takes ten minutes, and it preserves the plan and the review.
Bypassing the process is sometimes correct. Somebody changes a security group in the console at 03:00 to stop an active incident. That is the right call. It is also a change that now exists in reality and not in the repository.
The follow-up is the part that matters. The change must reach the repository, promptly, or it will be silently reverted by the next apply. That is not a hypothetical: the next terraform apply sees a resource that does not match configuration and changes it back — during business hours, with nobody connecting the outage to a change made two days earlier.
The convention worth adopting:
-
Make the emergency change. Stop the incident.
-
Record what was changed, immediately. In the incident channel, in the ticket, wherever it will survive.
-
Open a pull request bringing the repository in line, the same day. Not “when things calm down”.
-
Plan it. An empty plan means the repository already matches. A non-empty plan shows exactly what the manual change did — which is often more accurate than anybody’s memory of it.
-
Merge and apply, closing the gap.
-
Ask whether the process should have allowed it. If the normal path could not have shipped the fix in time, that is a finding about the process, not about the person who bypassed it.
The habit that prevents the worst outcome is step 4. A plan run after a manual change is the most reliable record of what actually happened.
Merge conflicts in infrastructure code
Section titled “Merge conflicts in infrastructure code”The mechanics are ordinary Git. The consequences are not.
A conflict in HCL is resolved by a human, and the result was never planned. Both branches had a reviewed plan; the merged configuration is a third thing that neither plan describes. The rule that follows: a pull request that had conflicts must be re-planned before merging, and ideally re-reviewed.
Conflicts in .tfvars are the dangerous ones. Two people changing values in the same environment file produce a conflict where either resolution is syntactically valid and one is wrong. A conflict in main.tf usually fails to parse if you get it wrong; a conflict in a values file quietly applies the wrong instance size.
.terraform.lock.hcl conflicts are common and easily mishandled. Two branches upgrading different providers both touch it. Do not hand-merge it — take one side and re-run terraform init -upgrade to regenerate. Hand-merging produces a lock file with inconsistent hashes that fails on somebody else’s machine.
Conflicts in generated files should not happen. If they do, the file should not be committed.
The structural fix is the same as everything else in this lesson: short branches. Two engineers touching the same environment file within a day is rare. Within three weeks it is close to certain, and the conflict arrives at the moment somebody is trying to merge in a hurry.
Commit messages that survive an incident
Section titled “Commit messages that survive an incident”Worth more in an infrastructure repository than almost anywhere else, because the audience is somebody at 2am who was not involved.
Say what changed and why, not what the diff shows. “Increase RDS max_connections to 500” is visible. “Increase RDS max_connections to 500 — connection pool exhaustion during the 14 Aug incident, see INC-1042” is the sentence that ends a future investigation in ten seconds.
Name the environments affected. A reader running git log on the production directory wants to know whether this reached production.
Reference the incident, issue or requirement. The link is what carries the context the message cannot.
Call out destroys in the message. If the apply destroyed something, the commit that caused it should say so.
Do not squash a promotion into an unrelated change. A commit that both bumps a module version and refactors a variable is a commit somebody will have to unpick.
The test: git log --oneline on an environment directory should read as a plausible history of that environment. If it reads as a list of “update terraform”, the history exists but does not inform anything.
Branch protection for infrastructure
Section titled “Branch protection for infrastructure”Stricter than most application repositories, for reasons the pillar has established.
No direct pushes to the default branch. Including for administrators. A short bypass list is the difference between a rule and a suggestion.
Required status checks: fmt, validate, and a successful plan.
Required review, and for production paths, required review from a code owner.
Dismiss stale approvals on new commits. Critical here: an approval given against one plan should not carry over to a different one.
Require the branch to be up to date before merging. This forces a re-plan against the current base, which is the mechanism that stops stale plans from merging.
Linear history, if your team likes it. Not a safety property, but it makes “what changed and when” easier to read during an incident, which is when you will care.
That fifth setting is the one specific to infrastructure. In an application repository, requiring branches to be up to date is about conflicts. Here it is about the plan being computed against the base that will actually be merged into.
Common mistakes
Section titled “Common mistakes”Git Flow, inherited from the application repositories. Long-lived branches, and staleness is the problem.
Long-lived feature branches. Plans expire silently.
Merging without re-planning after a base update. Applying a plan reviewed against a different world.
Not dismissing stale approvals. An approval for one plan carried onto another.
A large merge-to-apply gap. Deliberate drift nobody is tracking.
Emergency changes never brought back into the repository. Silently reverted later, at the worst possible time.
A long bypass list on the protection rule. The rule describes an intention.
Release branches used as environments. Different concepts; only one has an end date.
Scaling to a larger team
Section titled “Scaling to a larger team”The model above assumes a handful of engineers touching one repository. Above roughly ten regular contributors, three pressures appear.
Apply contention. State locking serialises applies per root module. With one large root module and fifteen engineers, people queue — and queuing encourages batching, which produces larger changes with larger plans, which is the opposite of what you want. The fix is smaller root modules, not a different branching model.
Review latency becomes the gap. If a pull request waits two days for a reviewer, plans go stale as a matter of routine rather than exception. At that point the constraint is reviewer availability, and CODEOWNERS routing plus a realistic view of who can review production changes matters more than any branch policy.
Coordination on shared modules. A change to a widely consumed module affects everyone. Extracting it to a versioned module converts an implicit coordination problem into an explicit one: consumers upgrade deliberately rather than being changed underneath.
What does not help at scale: adding branches. The instinct to introduce an integration branch where changes accumulate before reaching main reproduces Git Flow’s problems — a growing set of merged-but-unapplied changes, and plans computed against a base that is not the one that will be applied.
The scaling answer is smaller units of change and smaller units of state, both of which reduce contention without adding branch topology.
Mental model
Section titled “Mental model”An infrastructure branch holds a plan, and a plan is perishable. Branching strategy is the discipline of merging and applying before the evidence goes off.
Everything follows. Short branches keep plans fresh. Requiring an up-to-date branch forces a re-plan. Dismissing stale approvals stops a review outliving its evidence. Minimising the merge-to-apply gap keeps the repository honest about what exists.
What you learned
Section titled “What you learned”- Infrastructure branching is governed by plan staleness rather than merge conflicts
- Trunk-based with short-lived branches is the default; GitHub Flow is equivalent here
- Git Flow’s long-lived branches add exactly the property that hurts infrastructure
- Release branches fit module repositories with supported versions, not environments
- The merge-to-apply gap is drift you created, and most teams do not measure it
- Emergency console changes must reach the repository the same day or they get silently reverted
- Require branches to be up to date, and dismiss stale approvals — both protect the plan
Exercise
Section titled “Exercise”Use a disposable repository. No cloud credentials.
-
Create a Terraform configuration with
local_fileresources and a pull request workflow that plans. -
Open branch A, change a value, note the plan. Do not merge.
-
Open branch B from the same base, change a different value, merge and apply it.
-
Return to branch A. Predict: is its plan still accurate? Re-plan and compare.
-
Enable “require branches to be up to date before merging”. Try to merge branch A. Predict: what does GitHub require first?
-
Enable “dismiss stale pull request approvals”. Approve a pull request, push another commit. Predict: does the approval survive?
-
Change a
local_fileoutside Terraform — edit it directly. Runplan. Predict: what does Terraform propose, and what would it have done overnight? -
Delete the repository.