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:
- Manual creation: Each new service needs manual Application creation
- No hierarchy: All applications are peers, no logical grouping
- Difficult onboarding: New team members don’t know what exists
- 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: Applications10: 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:
- Namespaces exist before anything deploys
- Platform tools ready before apps need them
- 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.
