Three mechanisms, one of which is a trap, and the choice between the other two turns on a single question: is the isolation you need a permission boundary or a review boundary?
The question underneath
Section titled “The question underneath”Before comparing mechanisms, be clear about what the separation must deliver.
Blast radius. A mistake in development must not reach production. This is the requirement people reach for repository structure to solve, and it is usually enforced elsewhere — separate state files, separate cloud accounts, separate clusters. Getting that right first means the repository decision can be made for other reasons.
Permission separation. Some people may change development and not production. Path rules express who must approve; only a repository boundary controls who can read.
Visible differences. Somebody should be able to answer “how does production differ from staging” by reading rather than by running things and comparing.
A promotion path. A change proven lower down reaches production through a defined route.
Independent cadence. Production can be frozen while development moves.
Different mechanisms deliver different subsets. Working out which of the five you actually need is the decision; the mechanism follows.
Directories
Section titled “Directories”One repository, a directory per environment.
environments/├── development/├── staging/└── production/Delivers: visible differences (diff the directories), a clear promotion path (a pull request against a path), path-scoped ownership and rulesets, and independent cadence.
Does not deliver: read separation. Everybody with repository access sees production’s configuration.
The failure mode: directories drift. A resource added to production for an urgent reason and never added elsewhere, repeated over eighteen months, until staging predicts nothing. The counter is keeping environment directories thin and treating a resource that exists in only one as something requiring an explanation.
This is the right default. Most teams need a review boundary rather than a read boundary, and directories give it with a fraction of the overhead — one repository to clone, one CI configuration, one place to compare environments, and path-scoped rules that express most of what a separate repository would.
Separate repositories
Section titled “Separate repositories”infra-development/ infra-staging/ infra-production/Delivers: everything directories do, plus read separation. Production write access is a distinct grant, auditable at the repository level. Different rulesets, different collaborator lists, a separate audit trail.
Costs:
Cross-environment changes are several pull requests with an ordering nobody enforces. The second one is easy to forget, which leaves environments inconsistent in a way nothing reports.
Shared code must be versioned. A relative path does not cross a repository boundary, so modules need tags and a release process.
CI is duplicated or extracted into reusable workflows, which becomes a shared dependency with its own versioning question.
Comparing environments is harder. Diffing two repositories is more work than diffing two directories, and the drift is correspondingly less visible.
Onboarding is harder. No single place to read the estate.
When it is right: when read separation is a genuine requirement. Regulatory separation, a different team operating production, a contractor with limited scope, an audit regime wanting demonstrable access control. Not because it feels tidier.
Branches — and why not
Section titled “Branches — and why not”main is development, a staging branch is staging, a production branch is production. Promotion is a merge.
It is the mechanism that occurs to people who know Git well, and it degrades structurally rather than through lack of discipline.
Merges between long-lived branches accumulate conflicts. Every hotfix applied directly to production must be merged back. Miss one and the branches diverge permanently, with nothing to alert you.
Cherry-picking loses identity. Teams that give up merging start cherry-picking, and the commit in production has a different SHA. “Is this change in production?” stops having a reliable answer.
Environment configuration lives in the diff between branches. The difference between staging and production becomes the merge conflict everybody resolves the same way — until somebody resolves it differently, and an environment changes silently.
Reviews show the wrong effect. A pull request into staging shows a plan against staging. The same change into production may plan differently, and you cannot see that until you open the production pull request.
Rollback is a merge, not a revert. Reverting production means constructing a state that never existed on another branch.
It fights the tooling. GitHub environments, CODEOWNERS path rules, required checks and rulesets are all easier to express against paths than against a branch topology.
The one legitimate long-lived branch here is not an environment. A release branch receiving backported fixes for a version still in service has a defined support window and an end date. An environment branch tracks a running system, which has neither.
Comparison
Section titled “Comparison”| Directories | Repositories | Branches | |
|---|---|---|---|
| Read separation | No | Yes | No |
| Approval separation | Paths and rulesets | Repository | Awkward |
| Differences visible | Yes, by diff | Cross-repo | In merge conflicts |
| Promotion | PR against a path | PR bumping a version | Merge |
| Cross-environment change | One PR | Several, ordered | Merge chain |
| Drift visibility | Good | Poor | Poor |
| CI | One config, path-scoped | N configs | Awkward |
| Onboarding | One place | Which repository? | Which branch? |
| Verdict | Default | Hard isolation | Avoid |
Beyond environments
Section titled “Beyond environments”The same three mechanisms apply to other axes, and the reasoning transfers.
Clusters. A repository per cluster when clusters have different operators — different regions with different teams, or a tenant-per-cluster model. Directories otherwise.
Regions. Almost always directories or a generated overlay. Regions rarely need read separation from each other, and duplicating a repository per region produces enormous duplication.
Tenants. This is where separate repositories become genuinely necessary. If tenants must not see each other’s configuration, directories in a shared repository are insufficient — repository read access is the boundary. A tenant per repository, or a mechanism rendering per-tenant content into separate sources.
Business units. Follow the ownership map. If two units genuinely operate independently with different compliance requirements, separate repositories; if they merely have different budgets, directories.
The general rule: separate repositories when read access must differ. Directories otherwise. The exception is scale — thirty near-identical directories is a generation problem, and ApplicationSets or variable substitution is the answer rather than thirty repositories.
Governance
Section titled “Governance”What each mechanism lets you enforce.
With directories:
Path-scoped rulesets — production requiring two approvals and a code owner, development requiring one. CODEOWNERS routing production changes to the right people. Required status checks per path. Everything except who can read.
With repositories:
All of the above, plus a distinct collaborator list, a separate audit trail, and the ability to say truthfully that a given person cannot see production’s configuration. Also the ability to apply different retention, different integrations and different webhook targets.
With branches:
Branch protection per branch, which sounds equivalent and is not — the reviewer sees a merge rather than a change, CODEOWNERS cannot distinguish environments because the paths are identical, and the diff a rule protects is a merge conflict resolution.
The governance question worth asking: what would an auditor ask for? If the answer is “show that only these four people can change production”, a ruleset demonstrates it. If it is “show that only these four people can see production’s configuration”, only a repository boundary does.
Promotion across the mechanisms
Section titled “Promotion across the mechanisms”Directories: a pull request changing a reference in the target environment’s directory. One line, reviewable, and the diff between environments stays visible.
Repositories: a pull request in the target repository, bumping a module or image version. Same shape, more coordination, and no easy way to see how the environments differ.
Branches: a merge. The reviewer sees a set of commits rather than a change, and any conflict resolution is a configuration nobody reviewed.
In all three, promote a reference rather than content. An image digest, a module version, a chart version. Infrastructure promotion covers the pattern; the mechanism you choose affects how visible it is, not what should move.
Keeping environments comparable
Section titled “Keeping environments comparable”Whatever mechanism, the failure is divergence.
Compare them deliberately. A scheduled job diffing environment configurations, with every difference either explained or removed. In a directory layout this is diff -r; across repositories it is more work and more necessary.
Every difference should have a reason somebody can state. “Production has a replica because it needs redundancy” is a reason. “Production has this resource and nobody knows why” is a finding.
Keep the differences in values, not in structure. A resource that exists in only one environment is structural divergence; a variable with a different value is not. The first is how environments stop predicting each other.
Backport urgent changes the same day. An emergency change applied to production and not to staging is divergence with an origin story, and the origin is forgotten within a week.
Measure it. The number of unexplained differences between staging and production is a genuine health metric, and most teams have never counted it.
Migrating off environment branches
Section titled “Migrating off environment branches”The most common remediation in this lesson, and it is a project rather than a commit.
-
Diff every pair of branches and classify each difference as intentional, accidental, or unknown. The unknown pile is the largest and is the actual work.
-
Resolve the unknowns before moving anything. A difference nobody can explain is either a forgotten hotfix that must be preserved or an abandoned experiment that must not be, and you need to know which.
-
Build the directory layout on the default branch, alongside the existing branches. Nothing deploys from it yet.
-
Point each environment directory at the existing state or cluster. Same backend key, same namespace. You are changing which files describe the same resources, not creating anything.
-
Plan or render each environment from the new layout and expect an empty result. This is the verification step and the one that must not be skipped. Anything non-empty means the new configuration does not match reality.
-
Cut over one environment at a time, lowest first, leaving each running for a while before the next.
-
Delete the environment branches last, once nothing has deployed from them for a full cycle.
Step 5 is the migration. An empty diff proves both layouts describe the same infrastructure; everything after it is scheduling.
Expect step 2 to take longer than everything else. That is not a failure of the migration — it is the accumulated cost of the branch strategy becoming visible all at once, and it is the strongest argument for not adopting it in the first place.
Under a GitOps controller, take extra care. Repointing a controller at a new path can look like “these resources were deleted and these were created”. With pruning enabled, the deletion happens. Add the new path, verify the rendered output is identical, then remove the old one — never both in one commit.
Common mistakes
Section titled “Common mistakes”Branch per environment. Silently reverted hotfixes and unreviewed conflict resolutions.
Separate repositories for read separation you do not need. Coordination cost with no corresponding benefit.
Directories where read separation is a requirement. Path rules control approval, not visibility.
Thick environment directories. Structural divergence, and staging stops predicting production.
A repository per region. Enormous duplication for an axis that rarely needs isolation.
No comparison between environments. Drift accumulates unnoticed.
Cross-environment changes that stop halfway. The second pull request forgotten.
Assuming branch protection gives you what a repository boundary does. It governs approval, not access.
Ephemeral environments
Section titled “Ephemeral environments”The case none of the three mechanisms handles well, because the set is dynamic.
A preview environment per pull request, a load-test environment for an afternoon, a per-customer stack created on signup. Nobody can enumerate these in a directory tree in advance.
Generation is the answer, not a directory. An ApplicationSet with a pull request generator, a Terraform workspace created by a pipeline, or a templated overlay rendered per instance.
The rules that keep it safe:
A separate account or cluster from anything real. Ephemeral environments are created by automation from proposed code, which is the highest-risk input in this pillar.
Guaranteed teardown. A destroy job on pull request close, plus a scheduled sweep for the ones that failed. Without the sweep, the account accumulates orphans until somebody notices a bill.
A hard cap on concurrent environments, so a loop cannot create hundreds.
A time limit regardless of whether the pull request is open.
Nothing stateful with real data. Seeded fixtures. A preview environment restored from a production snapshot is production data in an environment built from unreviewed code.
They are not part of your promotion path. An ephemeral environment proves a change works in isolation; it does not substitute for staging, because it has none of production’s neighbours, scale or accumulated state.
Naming and consistency
Section titled “Naming and consistency”Small and worth getting right, because the names appear in more places than people expect.
One word per environment, everywhere. The directory, the namespace, the cluster, the cloud account alias, the GitHub environment, the ruleset, the CODEOWNERS pattern, the monitoring dashboard and the runbook. prod in one place and production in another is how somebody targets the wrong thing.
Avoid names that imply an ordering you do not have. env1, env2 tells nobody anything.
Avoid names that will become wrong. new-production is a name with a shelf life.
Make production’s name obvious. Whatever the layout, it should be immediately clear which paths affect production — that is what makes CODEOWNERS rules readable and what engages a reviewer’s attention.
Write the list down. Which environments exist, what each is for, and what decision each informs. An environment nobody can justify is one paying for itself in cloud spend and slowing every promotion.
What a separate production repository actually buys
Section titled “What a separate production repository actually buys”Worth being concrete, because teams adopt this for reasons that do not survive examination.
It buys read separation. A named list of people can see production’s configuration. Nothing else in this lesson provides that.
It buys an independent audit trail. Repository-level access logs, a separate collaborator history, and a clean answer to “who has had access to production configuration in the last year”.
It buys different integrations. Different webhooks, different apps installed, different retention. Occasionally a real requirement.
It buys a psychological boundary. Cloning a repository called infra-production engages a different level of care than opening a directory. This is not nothing, and it is also not a control.
What it does not buy:
Blast radius. That comes from separate state and separate accounts. A separate repository containing configuration for the same account as development isolates nothing.
Protection against a bad change. The same person with the same access can still make it. Rulesets and review are what catch that, and those work in a directory layout too.
Protection against drift. If anything, it makes drift harder to see.
Simplicity. It is strictly more coordination.
The honest test: if you cannot name somebody who should be able to change development and not be able to read production, the separate repository is buying you the psychological boundary and the coordination cost, and you could get most of the benefit from a ruleset and a clearly named directory.
Mental model
Section titled “Mental model”Directories give you a review boundary. Repositories give you a read boundary. Branches give you a merge topology, which is not a boundary at all — it is a source of conflicts whose resolution nobody reviewed.
Choose by asking what the isolation is for. Most teams need to control who approves, which directories do. A minority need to control who sees, which requires repositories. Nobody needs a merge topology.
What you learned
Section titled “What you learned”- Directories deliver a review boundary; only separate repositories deliver a read boundary
- Branch-per-environment fails structurally: divergence, cherry-picked identity, unreviewed conflict resolutions
- Its worst failure is a hotfix silently reverted by a later promotion merge
- Release branches are not environment branches — one has a support window, the other does not
- Tenants are the case where separate repositories are genuinely required
- Regions rarely need isolation from each other; generation beats duplication
- Every difference between environments should have a reason somebody can state
- Structural divergence is what stops staging predicting production
Exercise
Section titled “Exercise”Use disposable repositories.
-
Build the directory layout with
developmentandproduction, differing only in a values file. Diff them. Predict: does the diff fully explain the difference? -
Add a resource to
production/only. Diff again. Predict: is the difference still explainable from values? -
Add
CODEOWNERSgiving production a different owner, plus a ruleset requiring code owner review on that path. Open a pull request touching each. -
Try to prevent the development owner from reading production’s directory. Predict: can you?
-
Now split into two repositories. Grant one person access to only development. Confirm the read separation.
-
Make a change that must reach both. Predict: how many pull requests, and what enforces the order?
-
Create a
productionbranch, commit a fix only there, then merge astagingbranch into it. Predict: what happens to the fix? -
Delete the repositories.
Related lessons
Section titled “Related lessons”The GitOps and infrastructure repository templates are in the Professional Toolkit.