The first architectural decision in a GitOps setup is whether application code and deployment configuration live in the same repository.
Almost everything else follows from it, and it is the decision teams make fastest and revisit most painfully.
The first split: app or config
Section titled “The first split: app or config”One repository per application, containing both code and manifests. A change to the application and its deployment is one commit, one review, one merge. Simple, and it is what most teams start with.
Separate application and configuration repositories. The application repository builds an image. A configuration repository holds manifests, and promotion is a change there.
| Same repository | Separate config repository | |
|---|---|---|
| Code and deploy in one PR | Yes | No — two |
| Promotion between environments | Awkward | Natural |
| Who can change production config | Anybody with app write access | Separately controlled |
| The controller watches | An app repository | A config repository |
| Commit noise from automation | In the app repository | Contained |
| Cross-application changes | N repositories | One place |
| Cognitive overhead | Low | Moderate |
The argument for separating is promotion. With a config repository, promoting a version is a one-line pull request changing an image digest — reviewable, attributable, and identical in shape for every environment. With everything in one repository, promotion means either an environment branch, a directory nobody quite owns, or a pipeline that mutates the repository as a side effect.
The argument against is that two repositories means two pull requests for one change, with an ordering nobody enforces. A developer changing an application and its resource limits now opens one pull request in each, and the second is easy to forget — which produces a deployment that is running new code with old configuration and no obvious signal that anything is missing.
The practical answer for most teams: start together, split when promotion becomes painful — which is usually when you acquire a third environment or a second team. Splitting later is straightforward; the manifests move to a new repository and the controller is re-pointed. Splitting before the pain arrives buys coordination overhead in exchange for a problem you do not have yet.
The config repository
Section titled “The config repository”Once separated, the layout question becomes concrete.
clusters/├── dev/│ ├── apps/ # What runs here│ └── infrastructure/ # Ingress, monitoring, cert management├── staging/└── production/
apps/├── api/│ ├── base/│ └── overlays/│ ├── dev/│ ├── staging/│ └── production/└── web/
infrastructure/├── ingress-nginx/├── cert-manager/└── monitoring/clusters/ is what each controller points at. A cluster’s directory says which applications and which infrastructure components belong there.
apps/ holds each application’s base and environment overlays.
infrastructure/ holds cluster components — ingress, certificates, monitoring — which have a different ownership, a different change cadence and a different blast radius from applications.
Why the indirection. The cluster directory is a thin list of references; the app directory holds the actual configuration. Adding an application to staging is a one-file change in clusters/staging/apps/. Changing how the application is configured is a change in apps/api/overlays/staging/. Two different actions, with two different reviewers and two different levels of risk — and separating them means “should this run here at all” is a question somebody answers explicitly rather than one that gets bundled into a configuration change.
Directory, branch or repository per environment
Section titled “Directory, branch or repository per environment”The same question as Terraform environments, with the same answer.
Directories — the default. Differences are diffable, promotion is a pull request against a path, and one controller configuration can watch several paths.
Repositories — a real permission boundary. Production config write access is a separate grant. Costs cross-environment coordination and duplicated CI.
Branches — avoid, for the reasons in the Terraform lesson, plus one specific to GitOps: with a branch per environment, the controller watches production, and promotion is a merge. Merge conflicts between long-lived branches are resolved by a human into a state neither reviewer approved, and the resulting cluster state is that resolution.
Monorepo or polyrepo
Section titled “Monorepo or polyrepo”At scale, the config repository itself becomes the question.
One config repository for everything. Every application, every cluster, every environment. Atomic cross-cutting changes, one place to look, one CI configuration.
The costs appear at scale: one repository that hundreds of pull requests touch; CODEOWNERS doing all the ownership work; a controller that must be scoped carefully or it watches everything; and a single blast radius.
A config repository per team or per domain. Ownership matches repository boundaries. Smaller blast radius. Teams move independently.
The costs: a cross-cutting change is N pull requests; shared components need a home; a new joiner has a discovery problem; and the controller needs a source per repository.
A config repository per cluster. Natural when clusters have genuinely different owners — different regions with different operators, or a tenant-per-cluster model.
| Scenario | Layout |
|---|---|
| One team, a few services | One repository, code and config together |
| One team, several environments | One config repository, directory per environment |
| Several teams, one platform | One config repository, CODEOWNERS per path |
| Several teams, strong isolation | Config repository per team |
| Regulated production | Separate production config repository |
| Many clusters, different operators | Config repository per cluster |
Scoping the controller
Section titled “Scoping the controller”The layout only works if the controller’s scope matches it.
One controller instance per cluster, pointed at that cluster’s directory. The most common arrangement.
Applications defined declaratively. Both Argo CD and Flux let you define what to watch as resources in the repository — an Argo CD Application, or a Flux Kustomization. That means adding an application is a commit rather than a console action, which is the property you want.
The app-of-apps pattern. A root Argo CD Application pointing at a directory of Application resources. Adding an app is adding a file, and removing one is deleting a file — which, with pruning enabled on the root, removes the application and everything it deployed. That is the intended behaviour and it is worth knowing before somebody tidies up a directory. Flux achieves the same shape with a Kustomization that reconciles a directory of further Kustomization resources.
Scope RBAC to match. A controller with cluster-admin across everything has a blast radius equal to the cluster. Per-namespace scoping is more work and considerably better.
Watch the reconcile scope, not just the repository. A controller pointed at a repository root reconciles everything in it, including directories intended for other clusters. Path scoping is the mechanism, and getting it wrong means one cluster applying another’s configuration.
Infrastructure components
Section titled “Infrastructure components”Cluster components — ingress controllers, certificate management, monitoring, CNI add-ons, the GitOps controller itself — are different from applications and deserve a different treatment.
They change on a different cadence. An application deploys several times a week. An ingress controller is upgraded quarterly, and that upgrade is a much bigger event.
They have different owners. Usually a platform team rather than the application teams.
Their failure modes are cluster-wide. A broken application affects one service. A broken ingress controller affects everything behind it.
They have ordering dependencies. Cert-manager must exist before anything requests a certificate. CRDs must exist before custom resources referencing them. Applications generally do not have this problem and infrastructure components routinely do.
The practical consequences for layout:
Separate directory, separate ownership. infrastructure/ under a platform team’s CODEOWNERS entry.
Often a separate reconciler configuration, so infrastructure syncs before applications. Both controllers support expressing dependencies — Flux’s dependsOn, Argo CD’s sync waves — and infrastructure is where you actually need them.
Pin versions explicitly and upgrade deliberately. A Helm chart for an ingress controller referenced by a floating version is a cluster component that changes when the chart’s maintainer publishes, which is not a decision anybody on your team made.
Test upgrades in a real cluster. An application upgrade can often be validated by rendering the manifests. An infrastructure component upgrade needs somewhere to actually run, because the failures are behavioural — a webhook that rejects existing resources, a CRD schema change, a controller that needs a migration.
Bootstrapping
Section titled “Bootstrapping”The chicken-and-egg problem every GitOps setup has: the controller manages everything declaratively, and something has to install the controller.
The bootstrap is imperative and that is fine. A CLI command, a Helm install, a Terraform resource. It happens once per cluster.
The controller then manages itself. After bootstrap, the controller’s own configuration lives in the repository and it reconciles its own upgrades. Flux’s bootstrap command does this explicitly — it commits its own manifests to your repository as part of the process.
Record how the bootstrap was done. A cluster somebody set up by hand eighteen months ago, with no record of the command or the version, is a cluster nobody can rebuild. A script or a Terraform module in the repository is the difference between a documented cluster and an artefact.
What must exist before the controller can work: the cluster itself, network access to the source repository, and credentials for that access. Those are provisioning concerns — usually Terraform — and they sit outside the GitOps loop by necessity.
The recovery question worth answering in advance: if this cluster is destroyed, what is the sequence to rebuild it? For a well-structured setup the answer is Terraform for the cluster, one bootstrap command, and then the controller reconciles everything else. That is a genuinely good position and it is worth verifying rather than assuming, because the parts nobody wrote down are exactly the parts that will be missing.
Shared configuration
Section titled “Shared configuration”Every layout needs somewhere for what is common.
Kustomize bases for shared structure. An application’s base holds the resources; overlays hold environment differences.
A shared components directory for cross-application patterns — a standard set of labels, a common NetworkPolicy shape, a PodDisruptionBudget template. Kustomize components are designed for this.
Helm charts for anything genuinely packaged, particularly third-party.
The trap is over-abstraction. A base with fourteen patch points serving six applications that all patch different things is harder to reason about than six explicit configurations. Duplication is cheaper than the wrong abstraction, and Kubernetes YAML is a domain where that is especially true because the abstraction cost is paid at review time by everybody.
The test: can somebody read one overlay and know what will be applied? If they have to trace through three levels of bases and components, the abstraction is costing more than the duplication would.
Multi-cluster and multi-tenant
Section titled “Multi-cluster and multi-tenant”The layout that works for three clusters needs adjusting at thirty.
The naive extension — a directory per cluster, copied — produces enormous duplication. Thirty near-identical cluster directories means thirty places to make the same change.
Group clusters by what they have in common. Region, tier, tenant, or purpose. A clusters/production/eu-west-1/ and clusters/production/us-east-1/ that both reference a shared production base is the same overlay pattern applied one level up.
Templating the cluster definitions themselves is where Argo CD’s ApplicationSet and Flux’s variable substitution come in: one definition, generated across a set of clusters or environments. This is genuinely the right tool at scale and genuinely over-engineering at three clusters.
Distinguish “the same application in many clusters” from “many different applications”. The first is a generation problem with a good tooling answer. The second is an ownership problem with a repository-structure answer.
Tenant isolation is a harder requirement than environment separation. If tenants must not see each other’s configuration, directories in a shared repository are insufficient — repository read access is the boundary, and that means a repository per tenant or a mechanism that renders per-tenant content into separate sources.
The scaling failure to watch for: a single repository where thirty clusters’ controllers all reconcile against the same Git source. Every push causes thirty reconciliations, the repository becomes a coordination point, and one bad commit reaches everything simultaneously. Staged rollout — clusters watching different tags or branches with a deliberate promotion between them — is the answer, and it is a real design rather than a default.
Naming and discoverability
Section titled “Naming and discoverability”A repository somebody can navigate without asking is worth more than a clever one.
Name directories after what they are, not what tool made them. apps/api/ rather than kustomize/api/. The tool can change; the application will not.
Use one name for an environment everywhere. The directory, the namespace, the cluster, the Argo CD project, the Git branch protection rule. prod in one place and production in another is how somebody applies to the wrong thing.
A README at the repository root answering four questions: what is this, which clusters does it manage, how do I add an application, and who to ask. Four paragraphs, and it is the difference between a repository people can contribute to and one where every change starts with a message.
A README per application stating what it is and who owns it. Manifests describe configuration; nothing describes purpose.
Keep the top level shallow. Three or four directories a reader can hold in their head. Depth is fine below that; breadth at the root is not.
Avoid names that suggest an ordering. 01-infrastructure/, 02-apps/ encodes a dependency in the filesystem, which is wrong the moment the order changes and misleading the moment somebody inserts something between them. Dependencies belong in the controller’s configuration, where they can be expressed properly.
The test: could somebody who has never seen the repository add a new application to the development cluster, correctly, using only what is written down? If not, the gap they hit is the next thing to document.
Common mistakes
Section titled “Common mistakes”Branch per environment. Merge conflicts resolved into a state nobody reviewed, and silently reverted hotfixes.
Automation committing to the application repository. History noise and recursive CI triggers.
A controller watching the repository root. Applies configuration meant for other clusters.
cluster-admin for the controller. Blast radius equal to the cluster.
Over-abstracted bases. Nobody can tell what an overlay produces.
Adding applications through a console rather than a commit. The repository is no longer the source of truth for what runs.
No CODEOWNERS in a monorepo config repository. Paths are the only ownership boundary available.
Splitting into per-team repositories too early. Coordination cost with no corresponding benefit.
Evolving the layout
Section titled “Evolving the layout”Repository structure should change. The signals that it needs to, and what to do.
Pull requests routinely touch unrelated paths. The boundaries do not match how work actually arrives. Look at what changes together and redraw.
Reviewers do not recognise most of a diff. The repository has outgrown its ownership map. CODEOWNERS per path first; splitting only if that is insufficient.
CI takes long enough that people wait. Path-scoped validation before splitting anything — most Kubernetes repositories can validate only what changed.
Automated commits outnumber human ones. Time to separate config from application code.
Somebody cannot find where something is configured. A discovery problem, and the fix is usually a README rather than a restructure.
One team is blocked on another’s reviews. The strongest signal for a real split, because it is a coordination cost that grows.
How to move things safely. Restructuring a GitOps repository is riskier than restructuring an application one, because a controller is watching. Moving a directory can look like “these resources were deleted and these were created” — and with pruning enabled, the deletion happens.
The safe sequence: add the new location, point the controller at both, verify the rendered output is identical, then remove the old location. Never move and re-point in one commit. Verify with a rendered diff that the change is genuinely a no-op before merging, because a controller cannot tell a move from a delete-and-create.
Where the image digest lives
Section titled “Where the image digest lives”A small decision with a large effect on how promotion works.
In the environment overlay, as a Kustomize images entry or a Helm value. This is the common arrangement and it makes promotion a one-line change to a specific file — apps/api/overlays/production/kustomization.yaml gains a new digest.
Not in the base. A digest in the base means every environment runs the same version, which removes the ability to promote at all.
Not resolved at deploy time. A manifest saying image: api:latest resolved by the cluster at pull time means the repository does not record what is running, and two nodes pulling at different moments can get different images.
The file that holds it should be the one automation edits. A promotion pull request opened by a bot should change exactly one line in one file, which makes it reviewable at a glance and makes the diff identical every time — so a reviewer notices immediately when it is not.
Keep the human-readable version beside it in a comment. A digest tells a reviewer nothing about what they are approving; # v2.4.1 next to it tells them everything they need.
This is the mechanism that makes environment promotion work, and getting the file layout right is most of what makes it pleasant.
Mental model
Section titled “Mental model”The cluster directory says what runs where. The application directory says how it is configured. The controller’s scope is what makes the first one real.
Keep those three aligned and the layout works at any size. Let them diverge — a controller watching more than its cluster directory, or an application configured somewhere the cluster directory does not reference — and the repository stops describing the system.
What you learned
Section titled “What you learned”- The first decision is whether config lives with application code; promotion is the main argument for splitting
- Automation committing to an application repository is the usual practical trigger for splitting
- Directory per environment is the default; repositories give a permission boundary; branches fail
- The branch-per-environment failure is a hotfix silently reverted by a later promotion merge
clusters/references,apps/configures — two actions, two reviewers- Define applications declaratively so adding one is a commit
- Scope the controller’s path and RBAC, not just its repository
- Duplication is cheaper than the wrong abstraction in Kubernetes YAML
Exercise
Section titled “Exercise”Use a disposable local cluster and repository.
-
Build the
clusters/,apps/,infrastructure/layout with two environments and one application. -
Add the application to
clusters/dev/apps/only. Point a controller atclusters/dev/. Predict: does it deploy? -
Point a second controller configuration at the repository root instead. Predict: what does it try to apply?
-
Add an environment overlay changing the replica count. Render both overlays and diff them. Predict: is the difference obvious?
-
Add
CODEOWNERSgiving different owners toapps/andclusters/production/. Open a pull request touching each. -
Simulate the branch failure: create a
productionbranch, commit a fix only there, then merge astagingbranch into it. Predict: what happens to the fix? -
Delete the cluster and repository.
Related lessons
Section titled “Related lessons”The GitOps and infrastructure repository templates are in the Professional Toolkit.