GitOps with ArgoCD: A Mono-Repo Layout for Helm-Based Microservices
The first GitOps setup I built was the wrong shape. Each microservice had its own repository, each repository had its own Helm chart, and each chart had three near-identical values-{env}.yaml files that drifted apart over months. Updating a shared label took 30 pull requests across 30 repos. We refactored to a single mono-repo, and the operational pain dropped by an order of magnitude.
This post is the layout we landed on, the trade-offs that go with it, and the ArgoCD configuration that makes it work.
The repo layout
infra/
├── apps/
│ ├── checkout/
│ │ ├── chart/ # Helm chart (templates/, Chart.yaml, base values)
│ │ └── values/
│ │ ├── dev.yaml
│ │ ├── stage.yaml
│ │ └── prod.yaml
│ ├── search/
│ │ ├── chart/
│ │ └── values/
│ └── ...
├── platform/
│ ├── argocd/ # ArgoCD itself, bootstrapped manually once
│ ├── ingress-nginx/
│ ├── cert-manager/
│ ├── kube-prometheus-stack/
│ └── argo-applications/ # App-of-apps ArgoCD Applications live here
│ ├── dev/
│ ├── stage/
│ └── prod/
└── shared/
├── charts/ # Common subchart referenced by apps
└── policies/ # OPA/Kyverno policies applied cluster-wide
Three top-level domains: apps (the microservices), platform (everything below the application layer), and shared (truly common assets). I have tried fancier structures; this one is the smallest that still scales.
The app-of-apps pattern
The single most important ArgoCD pattern: a root Application per environment that points at a directory full of other Application manifests. ArgoCD reconciles the root, which creates/updates the children, which deploy the actual workloads. Adding a new service is a single PR that adds one file.
# platform/argo-applications/prod/root.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: prod-root
namespace: argocd
spec:
destination:
namespace: argocd
server: https://kubernetes.default.svc
source:
repoURL: https://bitbucket.org/org/infra.git
targetRevision: main
path: platform/argo-applications/prod
directory:
recurse: true
project: default
syncPolicy:
automated:
prune: true
selfHeal: true
Each child Application points at a specific app's chart and values file:
# platform/argo-applications/prod/checkout.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: checkout-prod
namespace: argocd
spec:
destination:
namespace: checkout
server: https://kubernetes.default.svc
source:
repoURL: https://bitbucket.org/org/infra.git
targetRevision: main
path: apps/checkout/chart
helm:
valueFiles:
- ../values/prod.yaml
project: default
syncPolicy:
automated:
prune: true
selfHeal: true
The chart is the same across environments. Only the values file changes.
Values file design
The chart's own values.yaml ships with safe-but-minimal defaults. Per-environment values/{env}.yaml overrides only what differs:
# apps/checkout/values/prod.yaml
replicaCount: 6
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 2Gi
image:
tag: v1.42.3
ingress:
host: checkout.example.com
Two rules I enforce in PR review:
- If a field is the same across all three environments, it belongs in the chart's default
values.yaml, not in per-env files. - If a field differs across environments, it must be in every env file (explicit), not relying on the default. Future readers should be able to
diffthe three env files and see the full story.
Image tags: the part that breaks without thought
The simplest version of GitOps has CI build the image, push it to ECR/SWR, and write the new tag into the appropriate values/{env}.yaml via a commit-back step. That works for dev. For prod, you usually want a human in the loop.
What we ended up with:
- CI on a feature branch builds the image, pushes it, opens a PR against
values/dev.yamlwith the new tag. - Merge auto-deploys to dev.
- Promoting to stage is a PR that copies the dev tag into
values/stage.yaml. ArgoCD picks it up on merge. - Promoting to prod is the same operation against
values/prod.yaml, opened by whoever did the dev release, reviewed by a second human.
This is more clicks than full automation, by design. Promotion is the moment to slow down.
Sync waves for ordering
When the order of resource application matters — install CRDs before the operators that use them, create namespaces before the workloads that live in them — ArgoCD's argocd.argoproj.io/sync-wave annotation orders within a single Application.
metadata:
annotations:
argocd.argoproj.io/sync-wave: "-1" # earlier than the default 0
I use waves sparingly. If you have three or more waves in one Application, it usually means you actually have multiple Applications hiding inside one.
The trade-offs
Mono-repo GitOps has real downsides. Code review of an infra PR competes with feature work in the same repo, blast radius of a bad merge is higher, and CI runs slower as the repo grows. I would still pick it over per-service repos every time, because the win on consistency is enormous.
The other trade-off worth naming: ArgoCD auto-sync + selfHeal + prune together means that any state on the cluster that does not exist in Git gets deleted. This is the correct default for prod (Git is the source of truth), and it punishes anyone who runs kubectl apply by hand. Make sure the team knows this before turning it on.
What is not in this layout
Secrets do not live in the repo, even encrypted. We use External Secrets Operator pulling from AWS Secrets Manager, with the secret references parameterized in values files. Helm hooks are kept minimal — they fight ArgoCD's sync model. Multi-cluster ApplicationSets are powerful but I would not start with them; one cluster at a time, generalize when you actually have a second cluster.
The whole layout fits on one screen of tree output, which is the point. Anyone who joins the team can find anything by reading filenames.