Why repository layout is the real GitOps design decision
Installing a GitOps controller takes an afternoon. Deciding what the repository looks like takes thought, and it is the choice you will live with longest. A bad layout produces merge conflicts between teams, unclear ownership, and promotion that means copying files by hand. A good one makes a new service a ten-line pull request.
This is part of our GitOps, Helm, ArgoCD and Flux service. It follows from installing the controller and feeds directly into environment promotion.
What good looks like
- Every deployed object traces to a path in git and a reviewer who approved it.
- A person new to the team can find where a service is configured in under a minute.
- Teams can change their own services without being able to change someone else’s or the platform’s.
- Adding a cluster or environment is mostly copying a folder and editing a few values.
- Nothing important lives only in the cluster or only in a pipeline variable.
The two questions to answer first
Config separate from code?
For most teams we recommend a dedicated config repository. The application repository builds and tests an image. The config repository says which image runs where. This avoids a common trap: a build pipeline that commits a new tag back into the same repository that triggered it, creating loops and noisy history.
The exception is a tiny team with one or two services, where keeping a deploy/ folder beside the code is simpler. Revisit that choice when a third team or a second cluster appears.
Mono or multi repository?
| Approach | Strengths | Weaknesses |
|---|---|---|
| One config monorepo | Easy search and refactoring, single place for standards, simple bootstrap | Permissions are coarse, noisy history, large repos can slow some controllers |
| Platform repo plus per-team repos | Strong team isolation, clean permissions | More repositories to bootstrap, harder cross-cutting changes |
| Repo per service | Maximum autonomy | Hard to see the whole estate, easy for standards to diverge |
Most scale-ups settle on the middle option: a platform repository owned by the platform team for ingress, observability and policies, and one or more application repositories for service teams.
How it works in practice
A layout we commonly start from
fleet-config/
clusters/
prod-eu/
root.yaml # points at apps and platform for this cluster
staging/
root.yaml
platform/
base/ # ingress, cert-manager, monitoring
overlays/
prod-eu/
staging/
apps/
payments/
base/ # deployment, service, config
overlays/
staging/
prod/
checkout/
base/
overlays/
Each cluster folder holds a single root object. That root is the entry point the controller watches, and everything else hangs off it.
App-of-apps in ArgoCD
The root is a parent Application whose source path contains child Application manifests:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root-prod-eu
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/acme/fleet-config.git
targetRevision: main
path: clusters/prod-eu/apps
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
Bootstrapping a new cluster then means applying this one object. For larger estates, generating the children is cleaner than hand-writing them, which we cover in ApplicationSets and multi-cluster deployments.
The Flux equivalent
Flux uses a chain of Kustomization objects. A root Kustomization syncs clusters/prod-eu, which contains further Kustomizations for platform and apps, each with dependsOn so that CRDs and add-ons are ready before applications arrive.
Ownership and guardrails
Repository permissions and CODEOWNERS are only useful if folders map to teams. We set rules such as: the payments team owns apps/payments, the platform team owns platform/ and clusters/, and production overlays require a second approver. Combine this with GitOps RBAC so the controller enforces the same boundaries the repository does.
Pull request checks catch broken manifests before merge: kustomize build for every overlay, kubeconform against the Kubernetes schemas, and a policy check for things like missing resource limits or privileged containers.
Decisions and trade-offs
Rendered manifests or templates in git. Storing rendered YAML makes every change visible in a diff but adds a render step. Storing Helm values and Kustomize overlays keeps git small but makes the final output less obvious. We usually keep sources in git and let CI render a preview into the pull request.
Generated versus hand-written Applications. Hand-written children are easy to read at five services and tedious at fifty. We switch to generators when the pattern is clearly repeating.
When not to over-engineer. A single cluster with a handful of services does not need a four-level hierarchy. Start with the simplest structure that has clear ownership, and grow it when a real need appears.
A worked example: splitting a growing monorepo
A scale-up starts with one config repository and ten services. At forty services, the production folder has one approval rule for everyone and a single noisy history. Pull requests from unrelated teams conflict constantly. The fix is rarely to explode everything into one repository per service. We usually keep the platform and shared add-ons in one repository owned by the platform team, and give each product area its own application config repository, referenced by an ArgoCD project or a Flux GitRepository.
The split follows ownership, not technology. If two groups never review each other’s changes, they probably should not share a review queue. Moving is mechanical when the layout is clean: copy the folder with history preserved, update the Application source, and confirm the diff is empty before the old path is removed.
Common failure modes
- Branch per environment. Branches diverge and promotion becomes a merge conflict. We use folders.
- Everything in one folder. No ownership, no review boundaries, huge diffs.
- Secrets committed in plain text. Handled with Sealed Secrets or External Secrets Operator, never with exceptions.
- Copy-pasted overlays. Divergent copies hide real differences. Keep shared config in a base and overlays small.
- Build pipelines pushing to main. Use a dedicated bot identity, a narrow path, and ideally a pull request for production.
Delivery and handover
You receive the new or restructured repository, a contributing guide that explains where things go and why, CODEOWNERS and branch protection already configured, and CI checks wired to every pull request. We migrate services in slices alongside your engineers, so by the end they have done it themselves at least a few times.
How to tell it is working
Teams rarely need to ask where something lives. Production changes are small, reviewed pull requests. A new environment takes a folder copy rather than a project. If you are unsure which layout fits your team, get in touch and we will review what you have today.