The app-of-apps pattern in ArgoCD starts clean and turns messy fast. A root Application watches a directory. That directory fills up with Application objects. Some need to deploy before others. You add sync-wave annotations. Then more annotations. Eventually you’re reading YAML to understand deployment order instead of the folder structure.
Here’s the structure that fixes it.
The Problem with Flat App-of-Apps
A typical setup:
cluster/apps/
cert-manager.yaml # sync-wave: -3
external-secrets.yaml # sync-wave: -2
vault.yaml # sync-wave: -1
grafana.yaml # sync-wave: 2
prometheus.yaml # sync-wave: 2
my-app.yaml # sync-wave: 3
The root Application recurses cluster/apps/ and deploys everything. Order is communicated through annotations. To understand the deployment sequence, you open each file and read the wave number.
This works. It doesn’t scale well. Adding a new component means deciding a wave number and checking whether it conflicts with existing ones. The structure gives no clue about layering.
The Constraint That Shapes Everything
In ArgoCD’s app-of-apps, all Application objects must be created in the argocd namespace. The root Application has destination.namespace: argocd. Every file it recurses is applied there.
This means raw Kubernetes resources — Deployments, ConfigMaps, ExternalSecrets — cannot live in the same directory as Application objects. If they do, ArgoCD creates them in the argocd namespace, which is almost never correct.
This constraint forces a split:
- One location for Application objects (namespace: argocd is correct)
- Another location for raw resources (each Application specifies its target namespace)
The Structure
cluster/
infra.yaml ← Application, sync-wave: 0
apps.yaml ← Application, sync-wave: 1
infra/
vault/app.yaml ← Vault Helm Application
eso/app.yaml ← ESO Helm Application
eso/clustersecretstore-app.yaml ← ClusterSecretStore Application (wave 1 within infra)
apps/
monitoring/
secrets.yaml ← Application pointing at base/monitoring/
prometheus.yaml ← Application (Helm chart)
grafana.yaml ← Application (Helm chart)
my-service/
app.yaml ← Application (Helm chart)
secrets.yaml ← Application pointing at base/my-service/
base/
monitoring/
externalsecret.yaml
my-service/
externalsecret.yaml
configmap.yaml
The root Application watches cluster/ with recurse: false. It finds only infra.yaml and apps.yaml.
Two Annotations, Everything Else Falls Out
# cluster/infra.yaml
metadata:
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
source:
path: cluster/infra
directory:
recurse: true
# cluster/apps.yaml
metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
source:
path: cluster/apps
directory:
recurse: true
Infra deploys and reaches Healthy. Apps starts. That’s the entire ordering story — two annotations, two files. Nothing inside infra/ or apps/ needs wave annotations for the cross-layer ordering.
What Goes Where
infra/ — operators, CRD installers, secret backends. Anything that must exist before workloads start. Vault, ESO, cert-manager, Prometheus Operator, ingress controllers.
apps/ — workloads that consume what infra provides. Monitoring stacks, runners, your services. Application objects only — no raw K8s resources.
base/ — raw K8s resources (ExternalSecrets, ConfigMaps, Deployments for anything not using a Helm chart). Referenced by Applications in apps/ as their source path. The base/ directory has no Application object pointing at it directly — it’s a library, not a layer.
Comparing to Flux
Flux dependsOn achieves the same thing:
# Flux
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: apps
spec:
dependsOn:
- name: infra
In ArgoCD you don’t have dependsOn — sync-waves on the parent Applications are the equivalent. Two annotations on two files is the ArgoCD version of a two-entry dependsOn chain.
Takeaway
Two sync-wave annotations at the top level communicate the deployment order to anyone reading the repository. The folder names (infra/, apps/) make the layering obvious without opening files. Raw manifests live in base/ because ArgoCD’s namespace constraint forces Application objects and raw resources to be separate. The constraint is not a limitation — it’s what creates the clean split.