ArgoCD App-of-Apps Structure That Communicates Ordering Without Annotations Everywhere

Mar 7, 2026

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:

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.