CGOA — GitOps Certified Associate
How to Split the Repository — and Make Promotion a One-Line PR
In one line
The first design decision in GitOps is not choosing a tool but the repository boundary. Whether to separate app source from deployment configuration, whether to split repositories by team, whether to separate environments by branch or by directory — the answers to these four questions determine every operating cost that follows.
Why this was needed
Keeping a k8s/ directory inside the app repository and managing manifests together with the code is convenient at first. But soon something like this happens.
When a commit that bumps the image tag lands in the app repository, that commit triggers CI again. CI builds a new image and commits its tag again. It is an infinite loop. Once you start attaching [skip ci] to work around it, the pipeline starts to get messy.
The second problem is permissions. The people who may review app code and the people who may review production deployment configuration are not the same set. If both are in the same repository, you have to force a split with CODEOWNERS, and a single mistake breaks through.
The third is the change cadence. App code changes ten times a day, while deployment configuration changes once a month. When the histories are mixed, finding "since when has this replicas value been 3?" means digging through hundreds of commits.
So the default form in practice is to split an app repository (code + Dockerfile + CI) from a config repository (manifests + kustomize/Helm values). The exam asks about this as separation of concerns.
How it works
How many config repositories to keep is the monorepo versus polyrepo debate.
| Monorepo (one config repo) | Polyrepo (one per team/app) | |
|---|---|---|
| Consistency | A common base is enforced in one place | Each repository ends up with its own standard |
| RBAC | Directory-level, so fine-grained control is hard | Cleanly separated by repository permissions |
| Atomic changes | Several apps can be changed together in one PR | Requires coordination across repositories |
| Agent load | Many apps poll a single repository at once | Distributed |
| Suitable scale | One to several teams, dozens of apps | Several organizations, hundreds of apps |
There are also three ways to separate environments.
- Branch separation —
dev/staging/mainbranches. It is intuitive, but the diff between branches mixes environment differences with time differences, and cherry-pick hell opens up. It is not recommended these days. - Directory separation (overlays) —
overlays/dev,overlays/stage,overlays/prod. You can see the differences among the three environments side by side in a single commit. It is the de facto standard today. - Repository separation — only the production configuration lives in a separate repository. It is used in regulated industries when the audit boundary is drawn at the repository.
Making a promotion one line
There is one criterion for telling whether a structure is good. "How many lines is the diff of the PR that promotes what ran well in stage to prod?"
If you split the base and overlays properly, the answer is one line.
# apps/checkout/overlays/prod/kustomization.yaml
images:
- name: ghcr.io/labhub/checkout
newTag: 1.5.0 # ← 1.4.0 에서 이 줄만 바뀐다
The information this one-line diff gives the reviewer is enormous. It proves at a glance that "the only change going to prod is the image tag, and the rest of the configuration is identical to stage." Conversely, if the promotion PR also changes replicas, resources, and environment variables, it is not a promotion but a new deployment.
The tag here must be immutable. If you use latest or a branch-name tag, the Git commit stays the same but the image that actually runs changes. Git can no longer fully determine the desired state, so the second principle is broken.
app-of-apps
As the number of apps grows, the Argo CD Application resources themselves grow to dozens. If you create them by hand, "the procedure for deploying the deployment tool" becomes manual again. App-of-apps is the pattern that closes this recursion.
A single root Application points at the bootstrap/ directory, and that directory holds the child Application manifests. Syncing the root creates the child Applications, and each child syncs its own app. Adding a new app becomes a matter of committing one file to bootstrap/.
It is a different kind of thing from ApplicationSet, which serves a similar purpose. App-of-apps lists the children explicitly as files, while ApplicationSet computes the children with generators (directory scan, cluster list, PR list). If you want the app list to be explicit, use app-of-apps; if the list changes often and can be expressed as a rule, use ApplicationSet.
What it looks like in the field
In the author's homelab, Argo CD is at 10.0.0.201 and Gitea is at 10.0.0.200. Argo CD pulls from the Gitea inside the same cluster. The interesting trap in this setup is the bootstrap order — if Gitea dies, Argo CD cannot read the desired state, and Gitea's own manifests are also inside that Gitea. That is why where to draw the boundary between the area managed by GitOps and the area bootstrapped by hand shows up as a real design problem.
One more thing. The Gateway API on this cluster needed CRD v1.6.1. With v1.2, tlsroutes and referencegrants were not v1, so the Cilium gateway controller refused to start. An incident like this teaches that cluster prerequisites such as CRD versions belong in the platform-layer repository, not in an app overlay, and that their sync wave must be moved earlier.
What you will do in the next lab
Under /root/cgoa-repo/, you create apps/checkout/base and overlays/dev|stage|prod yourself, and use kubectl kustomize to see how the per-environment render results diverge. Then you write the Application manifest, the app-of-apps root, and the AppProject as files. In the lab that follows, you will put the same structure onto a real cluster with kubectl apply -k.