Branch-per-environment GitOps patterns Jump to heading

In a GitOps setup the repository is the desired state and a controller reconciles reality to match it. That makes the repository layout a deployment topology decision rather than a stylistic one: whichever shape you pick determines how a change reaches an environment, how easy it is to compare two environments, and how visible drift is. Three layouts dominate, and the branch-per-environment one is both the most intuitive and the easiest to get wrong. This recipe compares them, within environment and deployment branches.

When to use this approach Jump to heading

  • A controller watches a repository and applies what it finds there.
  • You have three or more environments with meaningful differences.
  • Promotion between environments needs to be reviewable.
  • Someone has proposed branch-per-environment and you want the trade-offs first.
  • If you have one environment, keep one branch and one directory; the comparison below is about scale.

Step 1 — Understand what each layout optimises Jump to heading

# Branch per environment: same paths, different branches
git branch -r    # origin/dev  origin/staging  origin/production

# Directory per environment: one branch, parallel trees
find clusters -maxdepth 2 -type d    # clusters/dev  clusters/staging  clusters/production

# Repository per environment: hard isolation
# acme/gitops-dev  acme/gitops-staging  acme/gitops-production
Three layouts, three things they make easyBranches make promotion a merge and comparison a diff, but hide differences until you look. Directories make every environment visible in one tree at the cost of duplication. Separate repositories give hard isolation and make comparison awkward.Branch per envpromotion is a mergediff compares envsdrift is invisibleDirectory per envall visible at onceduplication is explicitno promotion stepRepo per envhard isolationseparate access controlcomparison is manualmost teams end up with directories, having started with branches
# Verification: which one are you actually running?
git branch -r | wc -l
find . -maxdepth 3 -name 'kustomization.yaml' | head

Step 2 — If you use branches, make the diff meaningful Jump to heading

The single advantage of branches is that comparing two environments is a Git command. That only holds while the branches differ by content rather than by history.

# What differs between staging and production right now?
git diff origin/production origin/staging --stat
# Which commits are in staging but not production?
git log --oneline origin/production..origin/staging
# Verification: the difference should be a prefix of history, not a divergence
git merge-base --is-ancestor origin/production origin/staging \
  && echo "production is behind staging, cleanly" \
  || echo "DIVERGED — content exists in production that is not in staging"

That last check is the one to automate. A divergence means someone changed an environment directly, and every subsequent promotion will either conflict or silently drop the change.

Step 3 — Keep environment differences out of the branch diff Jump to heading

The usual source of divergence is legitimate per-environment configuration — replica counts, resource limits, endpoints — committed onto the branch where it applies. Overlays express the same thing without divergence.

# One base, per-environment overlays, all on the default branch
# base/deployment.yaml
# overlays/staging/replicas.yaml
# overlays/production/replicas.yaml

kubectl kustomize overlays/production | head -20
# Verification: the branches now differ only by which commit they point at
git diff origin/production origin/staging -- base/ | wc -l    # expect 0 or small
Why per-environment values must not live on the branchA value committed onto an environment branch exists nowhere else, so the branch has diverged by definition and every promotion has to reconcile it. The same value in an overlay on the default branch is visible, reviewable and promotes cleanly.Where does a production-only value live?on the production branchDivergencepromotions conflictin an overlay on mainReviewablepromotes cleanlyin the deployment systemAlso fineoutside Git entirelythe left branch is how branch-per-environment earns its reputation

Step 4 — Detect drift in both directions Jump to heading

Two things can drift: the repository against itself, and the cluster against the repository.

# Repository drift: environment branches must remain ancestors of main
for env in dev staging production; do
  printf '%-12s ' "$env"
  git merge-base --is-ancestor "origin/$env" origin/main \
    && echo "clean" || echo "DIVERGED"
done
# Cluster drift: what is running against what the repository says
kubectl diff -k overlays/production 2>&1 | head -20
# Verification: both checks should be quiet in a healthy setup
kubectl diff -k overlays/production >/dev/null 2>&1 && echo "cluster matches the repository"

SAFETY WARNING — a controller with write access to your cluster and read access to a branch anyone can push to is a deployment path with no review in it. Protect environment branches at least as strictly as the default branch, restrict pushes to the promotion pipeline, and require review on the pull requests that feed them. A GitOps repository is production infrastructure, whatever it looks like.

Step 5 — Choose deliberately, and write the reason down Jump to heading

# A short decision record next to the layout it describes
cat > docs/adr/0007-gitops-layout.md <<'DOC'
# 7. Directory per environment

Status: accepted, 2026-09-18

We use one branch and a directory per environment.

Why: comparing environments is a file diff rather than a branch diff, and
per-environment values are visible in review rather than hidden on a branch
nobody reads. Promotion is a pull request that edits one image tag.

Rejected: branch per environment, because our earlier attempt diverged three
times in six months and each reconciliation took a day.
DOC
A promotion in the directory layoutPromotion is a pull request that changes one image tag in one overlay. The controller notices the merged commit and reconciles the environment to it, so the reviewable artefact and the deployment trigger are the same object.release jobpull requestmain branchcontrollerbump image tag in overlays/productionreviewed and mergedcontroller sees the commitreconcile the environmentdrift reported if it failsone reviewable diff per promotion, and it says exactly what changed

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Which layout should a new project pick? Jump to heading

Directory per environment, in most cases. It keeps every environment visible in one tree, makes differences explicit in review, and removes the promotion-by-merge step that causes most of the divergence problems. Branches become attractive at the point where environments need genuinely separate access control, and at that point separate repositories are usually the better answer.

Can we mix layouts? Jump to heading

You can, and the common mix is sensible: one branch with directories for the environments a team owns, plus a separate repository for anything with different access requirements. What does not work is directories inside a branch-per-environment layout, which gives you both sets of downsides.

How does this interact with a merge queue? Jump to heading

Well, because promotion pull requests are small and independent. The queue’s guarantee — that what lands was tested as the tree it produces — is exactly what you want before a controller applies it. The setup is in merge queues and required checks.