You start with one ArgoCD Application. Then five. Then twenty. Before you know it, you’re managing hundreds of Applications, and the manual overhead is killing your productivity.

The App-of-Apps pattern solves this: one root application that manages all other applications.

This is how I structure every GitOps repository, and it scales from homelab to enterprise.

The Problem: Application Sprawl

When you first adopt ArgoCD, you create Applications manually:

kubectl apply -f apps/frontend.yaml
kubectl apply -f apps/backend.yaml
kubectl apply -f apps/database.yaml
# ... repeat for every service

This works for small deployments. But it creates problems:

  1. Manual creation: Each new service needs manual Application creation
  2. No hierarchy: All applications are peers, no logical grouping
  3. Difficult onboarding: New team members don’t know what exists
  4. Bootstrap problem: How do you recreate all Applications after cluster loss?

The irony: you’re using GitOps for applications but not for the Applications themselves.

The Solution: App-of-Apps

App-of-Apps is simple: Applications that create Applications.

flowchart TD
    ROOT["Root Application<br/>(manages everything)"]

    ROOT --> INFRA["Infrastructure App"]
    ROOT --> PLATFORM["Platform App"]
    ROOT --> APPS["Apps App"]

    INFRA --> NS["namespaces"]
    INFRA --> RBAC["rbac"]
    INFRA --> NP["netpols"]

    PLATFORM --> MON["monitoring"]
    PLATFORM --> LOG["logging"]
    PLATFORM --> ING["ingress"]

    APPS --> FE["frontend"]
    APPS --> BE["backend"]
    APPS --> WK["workers"]

The root Application syncs a directory containing Application manifests. Those Applications sync actual workloads.

The Repository Structure

Here’s how I organize GitOps repositories:

gitops-repo/
├── clusters/
│   ├── production/
│   │   ├── root.yaml              # Root application
│   │   ├── infrastructure/
│   │   │   ├── app.yaml           # Infrastructure app-of-apps
│   │   │   ├── namespaces.yaml
│   │   │   ├── rbac.yaml
│   │   │   └── network-policies.yaml
│   │   ├── platform/
│   │   │   ├── app.yaml           # Platform app-of-apps
│   │   │   ├── monitoring.yaml
│   │   │   ├── logging.yaml
│   │   │   └── ingress.yaml
│   │   └── apps/
│   │       ├── app.yaml           # Workloads app-of-apps
│   │       ├── frontend.yaml
│   │       ├── backend.yaml
│   │       └── workers.yaml
│   └── staging/
│       └── ... (similar structure)
├── base/
│   ├── monitoring/
│   │   ├── kustomization.yaml
│   │   └── ...
│   └── ...
└── apps/
    ├── frontend/
    │   ├── base/
    │   └── overlays/
    │       ├── staging/
    │       └── production/
    └── backend/
        └── ...

Building the Hierarchy

Level 1: The Root Application

The root is the only Application you create manually:

# clusters/production/root.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/yourorg/gitops-repo.git
    targetRevision: main
    path: clusters/production
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

This syncs the clusters/production directory, which contains the next level apps.

Level 2: Category Applications

Group related applications by category:

# clusters/production/infrastructure/app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: infrastructure
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/yourorg/gitops-repo.git
    targetRevision: main
    path: clusters/production/infrastructure
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

This syncs all YAML files in the infrastructure directory — including the individual Applications.

Level 3: Leaf Applications

The actual workload applications:

# clusters/production/apps/frontend.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: frontend
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/yourorg/gitops-repo.git
    targetRevision: main
    path: apps/frontend/overlays/production
  destination:
    server: https://kubernetes.default.svc
    namespace: frontend
  syncPolicy:
    syncOptions:
      - CreateNamespace=true
    automated:
      prune: true
      selfHeal: true

Sync Waves: Controlling Order

Not everything can deploy simultaneously. Databases before apps. Namespaces before pods.

ArgoCD sync waves control deployment order:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: namespaces
  annotations:
    argocd.argoproj.io/sync-wave: "-10"  # Deploy first
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: monitoring
  annotations:
    argocd.argoproj.io/sync-wave: "-5"   # After namespaces
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: frontend
  annotations:
    argocd.argoproj.io/sync-wave: "0"    # Default, after platform

Lower numbers sync first. I use:

  • -10: Namespaces, RBAC
  • -5: Platform components (monitoring, logging, ingress)
  • 0: Applications
  • 10: Post-deployment jobs

Health Checks and Dependencies

ArgoCD waits for applications to be healthy before proceeding:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: database
spec:
  # ... source and destination
  syncPolicy:
    automated:
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
  # Health determines when "ready"

The database Application is healthy when its pods are running. Downstream applications in later sync waves wait for this.

Multi-Cluster with App-of-Apps

For multiple clusters, extend the hierarchy:

gitops-repo/
├── clusters/
│   ├── production-eu/
│   │   └── root.yaml
│   ├── production-us/
│   │   └── root.yaml
│   └── staging/
│       └── root.yaml

Each cluster has its own root. They can share base configurations through Kustomize overlays.

Adding a New Application

The power of App-of-Apps: adding a new application is just a file:

# 1. Create the Application manifest
cat > clusters/production/apps/new-service.yaml <<EOF
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: new-service
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/yourorg/gitops-repo.git
    path: apps/new-service/overlays/production
    targetRevision: main
  destination:
    server: https://kubernetes.default.svc
    namespace: new-service
  syncPolicy:
    syncOptions:
      - CreateNamespace=true
    automated:
      prune: true
      selfHeal: true
EOF

# 2. Create the application manifests
mkdir -p apps/new-service/overlays/production
# ... add your Kustomization

# 3. Commit and push
git add .
git commit -m "Add new-service"
git push

ArgoCD detects the new Application manifest and deploys it automatically. No kubectl. No UI clicking.

ApplicationSets: The Next Level

For truly dynamic application generation, consider ApplicationSets:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: apps
  namespace: argocd
spec:
  generators:
    - git:
        repoURL: https://github.com/yourorg/gitops-repo.git
        revision: main
        directories:
          - path: apps/*
  template:
    metadata:
      name: '{{path.basename}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/yourorg/gitops-repo.git
        targetRevision: main
        path: '{{path}}/overlays/production'
      destination:
        server: https://kubernetes.default.svc
        namespace: '{{path.basename}}'
      syncPolicy:
        automated:
          prune: true
          selfHeal: true

This generates an Application for every directory under apps/. Add a directory, get an Application.

Common Mistakes

Mistake 1: Too Deep Nesting

Three levels is usually enough. More creates confusion:

✗ root → category → subcategory → team → service → component
✓ root → category → service

Mistake 2: Forgetting Finalizers

Without finalizers, deleting an app-of-apps leaves orphan applications:

metadata:
  finalizers:
    - resources-finalizer.argocd.argoproj.io

This ensures child applications are deleted when parent is deleted.

Mistake 3: Circular Dependencies

Don’t have Application A depend on Application B depend on Application A.

Mistake 4: Everything in One Sync Wave

If everything deploys simultaneously, you get race conditions. Use sync waves.

My Standard Structure

After years of iteration, this is my go-to structure:

clusters/{env}/
├── root.yaml
├── infrastructure/           # Sync wave -10
│   ├── app.yaml
│   ├── namespaces.yaml
│   ├── rbac.yaml
│   └── network-policies.yaml
├── platform/                 # Sync wave -5
│   ├── app.yaml
│   ├── argocd.yaml          # ArgoCD manages itself
│   ├── monitoring.yaml
│   ├── logging.yaml
│   ├── ingress.yaml
│   └── cert-manager.yaml
├── data/                     # Sync wave -2
│   ├── app.yaml
│   └── databases.yaml
└── apps/                     # Sync wave 0
    ├── app.yaml
    └── {service}.yaml

This order ensures:

  1. Namespaces exist before anything deploys
  2. Platform tools ready before apps need them
  3. Databases running before apps connect

The Bootstrap Problem Solved

Remember GitOps Disaster Recovery? App-of-Apps makes recovery trivial:

# 1. New cluster
# 2. Install ArgoCD
# 3. Apply ONE file
kubectl apply -f clusters/production/root.yaml
# 4. Everything recreates

One command bootstraps your entire cluster. That’s the power of App-of-Apps.


App-of-Apps transforms GitOps from “files that deploy things” to “self-describing infrastructure.” Your Git repository becomes the complete specification of what should exist.