GitOps with ArgoCD & Flux

GitOps Repository Design

18 min Lesson 2 of 30

GitOps Repository Design

The single most consequential decision you make when adopting GitOps is how you lay out your repositories. Get this wrong and you will fight the tooling every day — drift between environments, unintended promotions, secrets leaking across team boundaries, and CI pipelines that are terrified to merge anything. Get it right and you have a delivery system that scales from one team and one cluster to fifty teams and hundreds of clusters with minimal additional overhead.

This lesson covers the three structural decisions every GitOps organisation must make: app repos versus infra repos, environment folders versus environment branches, and mono-repo versus multi-repo. Each decision has a real cost profile, and the right answer depends on your team topology, your compliance requirements, and your expected rate of change.

App Repos vs Infrastructure Repos

The most fundamental split in GitOps is between the repository that holds application source code and the repository that holds cluster-desired-state manifests. These are different concerns with different change rates, different audiences, and different access controls — keeping them separate is not just convention, it is architectural hygiene.

The app repo (sometimes called the "source repo") contains your Go, Python, TypeScript, or Java code, its Dockerfile, and the CI pipeline definition. Every merge to main triggers a build, runs tests, pushes an image tagged with the Git SHA, and — critically — opens a pull request against the infra repo updating the image tag. The app repo does not deploy anything directly.

The infra repo (the "config repo" or "GitOps repo") contains only Kubernetes manifests, Helm values files, or Kustomize overlays. It has no build artefacts and no application logic. Its single job is to be the source of truth for what should be running in every cluster. ArgoCD or Flux watches this repo and continuously reconciles the cluster to match it.

Why the separation matters: Application developers should be able to cut a release at any time without cluster-level permissions. Platform engineers should be able to change infrastructure (resource limits, network policies, HPA thresholds) without touching application code. A single repo conflates these responsibilities and creates merge bottlenecks at the worst possible moment — when you are trying to push a hotfix at 2 AM.

A concrete CI flow that wires these two repos together looks like this. Your GitHub Actions or GitLab CI workflow in the app repo builds and pushes the image, then uses a PAT or deploy key to open a PR against the infra repo:

# .github/workflows/release.yml (lives in the APP repo) name: Build & Promote on: push: branches: [main] jobs: build-and-promote: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build & push image run: | IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}" docker build -t "$IMAGE" . docker push "$IMAGE" - name: Bump image tag in infra repo env: GH_TOKEN: ${{ secrets.INFRA_REPO_PAT }} run: | gh repo clone org/infra /tmp/infra cd /tmp/infra # Use yq to patch the values file for the staging environment yq e -i '.image.tag = "${{ github.sha }}"' \ apps/checkout/staging/values.yaml git config user.email "ci@org.internal" git config user.name "CI Bot" git checkout -b "promote/checkout-${{ github.sha }}" git add apps/checkout/staging/values.yaml git commit -m "chore: promote checkout to ${{ github.sha }}" gh pr create \ --title "Promote checkout to ${{ github.sha }}" \ --body "Auto-promotion from app CI" \ --base main \ --head "promote/checkout-${{ github.sha }}"

The PR is reviewed by an oncall engineer (or automatically merged if you trust your integration tests), and ArgoCD/Flux picks up the merged change within seconds. This two-repo pattern is what Weaveworks, Google, and Lyft document as the canonical GitOps topology.

App repo to infra repo promotion flow App Repo source code Dockerfile · CI pipeline CI System build · test push image (SHA tag) push Infra Repo K8s manifests Helm values · Kustomize open PR GitOps Operator ArgoCD / Flux watches Cluster reconciled state applies
CI in the app repo builds and promotes to the infra repo; the GitOps operator continuously reconciles the cluster to the infra repo state.

Environment Folders vs Environment Branches

Once you have an infra repo, you face a second decision: how do you represent multiple environments (dev, staging, production) inside it? Two schools of thought exist, and one of them will cause you pain at scale.

Environment branches — a dedicated Git branch per environment (env/dev, env/staging, env/prod) — seem intuitive because "you promote by merging". In practice, branch-per-environment has catastrophic failure modes. Cherry-picks fail silently. Merge conflicts between env branches accumulate over weeks. You cannot easily diff "what is in staging but not prod" without comparing branch tips across the whole tree. Long-lived divergent branches are exactly what modern Git workflows were designed to eliminate. Do not use environment branches.

Environment folders — a single main branch with directories per environment — is the correct approach. Every change is a regular PR to main, the history is linear and readable, and diffing environments is a file-system diff, not a Git branch diff. Kustomize was purpose-built for this model; ArgoCD ApplicationSets and Flux Kustomization resources are designed to point at directories within a single branch.

A well-structured infra repo layout:

infra/ ├── apps/ # per-application configuration │ ├── checkout/ │ │ ├── base/ # shared base manifests │ │ │ ├── deployment.yaml │ │ │ ├── service.yaml │ │ │ └── kustomization.yaml │ │ ├── dev/ │ │ │ ├── kustomization.yaml # patches: 1 replica, dev image tag │ │ │ └── values.yaml # or Helm values overlay │ │ ├── staging/ │ │ │ ├── kustomization.yaml │ │ │ └── values.yaml │ │ └── prod/ │ │ ├── kustomization.yaml # patches: 3 replicas, PDB, HPA │ │ └── values.yaml │ └── payment/ │ └── ... ├── clusters/ # per-cluster bootstrap (Flux) or ArgoCD App definitions │ ├── dev-us-east-1/ │ │ └── apps.yaml # ArgoCD App-of-Apps or Flux Kustomization pointing at apps/*/dev │ ├── staging-eu-west-1/ │ │ └── apps.yaml │ └── prod-us-east-1/ │ └── apps.yaml └── platform/ # cluster-wide infra (ingress-nginx, cert-manager, monitoring) ├── ingress-nginx/ ├── cert-manager/ └── monitoring/
Folder promotion discipline: Treat environment directories like immutable deployment units. When staging has been soaking for 24 hours and metrics look clean, the promotion PR copies (or Kustomize-patches) the exact image tag from staging/values.yaml to prod/values.yaml. Nothing else changes. This makes the promotion diff a one-liner and the rollback a one-line revert — both auditable in Git history forever.

Mono-Repo vs Multi-Repo

The third axis is whether all your application infra config lives in one repository or is split per team or per service. This is where team topology — Conway's Law — matters most.

Mono-repo (all services in one infra repo) works extremely well for organisations up to roughly twenty to thirty teams. It gives you a single place to enforce policies (OPA/Kyverno policies are checked uniformly), makes cross-service dependency changes atomic (when you upgrade a shared Helm chart version you do it in one PR), and simplifies GitOps operator configuration (ArgoCD points at one repo). Google, Spotify, and Shopify successfully run mono-repos. The operational challenge is access control: teams must not be able to modify each other's directories. Solve this with CODEOWNERS files.

Multi-repo (one infra repo per service or per team) provides hard isolation boundaries and is the natural choice when regulatory compliance requires it (PCI DSS zone isolation, HIPAA data segmentation) or when teams are in separate legal entities. The cost is operator sprawl: every ArgoCD Application or Flux GitRepository object multiplies, cross-service changes need coordinated PRs in multiple repos, and policy enforcement must happen independently in each repo (or via a separate policy repo watched by an admission controller).

Production pitfall — the hybrid accident: Many organisations start with a mono-repo and gradually spin off repos as teams grow, ending up with a random mix of mono and multi patterns. This is the worst of both worlds: the GitOps operator config references dozens of different repos, secret rotation has to happen in thirty places, and onboarding a new engineer requires access grants to an ever-growing list of repositories. Decide your model early and enforce it consistently. If you outgrow mono-repo, migrate deliberately — do not drift.

A practical rule of thumb: start with a mono-repo split into teams/ subdirectories, enforced by CODEOWNERS. Migrate a team to their own repo only when there is a documented compliance or organisational boundary that makes it mandatory. Here is the CODEOWNERS pattern for mono-repo access control:

# infra repo root: .github/CODEOWNERS # Platform team owns cluster-level infra and must approve all changes there /platform/ @org/platform-team # Each app team owns their own directory; no one else can merge into it /apps/checkout/ @org/checkout-team /apps/payment/ @org/payment-team /apps/identity/ @org/identity-team # Cluster bootstrap files need platform approval /clusters/ @org/platform-team # Anyone on platform can approve root-level changes (policies, renovate config) * @org/platform-team

With branch protection requiring CODEOWNERS review, a developer on the payment team cannot accidentally — or maliciously — push a change that modifies the checkout service manifests. This single file enforces multi-team isolation without splitting into multiple repos.

Putting It All Together

The canonical big-tech GitOps repository design for a growing engineering organisation is: separate app and infra repos, environment folders on a single branch, and a mono-repo with CODEOWNERS until you hit a concrete reason to split. CI in the app repo promotes by opening PRs against the infra repo. The GitOps operator (ArgoCD or Flux) watches specific paths within the infra repo for each environment, applying changes automatically on merge. Drift detection and rollback become trivial — they are just Git operations on a linear history.

Key takeaway: Repository design is a social contract as much as a technical one. The directory structure you choose determines who can change what, how promotions are audited, and how quickly you can recover from a bad deploy. Design it for the team you will have in two years, not the team you have today.