GitOps with ArgoCD: A Mono-Repo Layout for Helm-Based Microservices

2026-04-30 11 min

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:

  1. If a field is the same across all three environments, it belongs in the chart's default values.yaml, not in per-env files.
  2. 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 diff the 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.yaml with 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.