My homelab started with one ArgoCD Application. Then a handful. The day I caught myself running kubectl apply -f for the fifteenth time to register yet another Application, I knew I’d built the exact thing I was trying to avoid: manual steps I had to remember, in an order I had to remember, with no record of what should exist.
The App-of-Apps pattern fixes that with one idea. You create a single root Application by hand, and it creates everything else. After a cluster wipe I can rebuild the whole thing with one kubectl apply. That property is the entire reason I run it, and it’s the same reason I self-host in the first place: I want the repository to be the truth, not my memory.
This is how I structure every GitOps repo now, and the shape holds whether it’s three apps in my homelab or a hundred at work.
The Problem: Application Sprawl
When you first adopt ArgoCD, you create Applications by hand:
kubectl apply -f apps/frontend.yaml
kubectl apply -f apps/backend.yaml
kubectl apply -f apps/database.yaml
# repeat for every service
This is fine for a couple of services. Past that it starts to hurt:
- Manual creation: every new service needs a
kubectl applyyou have to run and remember - No hierarchy: every Application is a peer, with no logical grouping
- Difficult onboarding: a new teammate (or future me) has no single place that lists what exists
- Bootstrap problem: rebuild a cluster and you have to recreate every Application by hand, in the right order
Here’s the part that always bugged me. You adopted GitOps so your cluster matches a Git repo. Then you went and created the Applications themselves with imperative commands that live nowhere. The deployments are declarative; the thing that defines the deployments is a pile of tribal knowledge.
The Solution: App-of-Apps
App-of-Apps is one rule: Applications can 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 full of Application manifests. Those Applications in turn sync the actual workloads. Once you see it, it’s obvious: an Application is just another Kubernetes resource, so of course ArgoCD can manage Applications the same way it manages Deployments.
The Repository Structure
Here’s how I lay out a GitOps repo:
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/
│ └── (same shape as production)
├── base/
│ ├── monitoring/
│ │ ├── kustomization.yaml
│ │ └── (manifests)
│ └── (more components)
└── apps/
├── frontend/
│ ├── base/
│ └── overlays/
│ ├── staging/
│ └── production/
└── backend/
└── (same shape)
Building the Hierarchy
Level 1: The Root Application
The root is the only Application you ever touch by hand. Everything below it is downstream of this one file:
# 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 holds the next layer of apps.
Level 2: Category Applications
Group related applications by what they are. I split mine into infrastructure, platform, and workloads, because those layers have different blast radius and different deploy timing:
# 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 every YAML file in the infrastructure directory, including the individual Applications inside it.
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
Some things genuinely have to come first. A namespace before the pods that live in it. A database before the app that connects to it. cert-manager before anything that wants a certificate. If everything fires at once you get a flurry of errors that all resolve on the next retry, which works but looks alarming and wastes time.
ArgoCD sync waves give you a deploy order without you having to orchestrate it:
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
Sync waves only matter because ArgoCD waits for one wave to be healthy before it starts the next:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: database
spec:
# source and destination as above
syncPolicy:
automated:
selfHeal: true
syncOptions:
- CreateNamespace=true
# Health determines when "ready"
The database Application reports healthy once its pods are running. Anything in a later wave that depends on it sits and waits until that’s true. You get ordering for free, derived from real health rather than a hardcoded sleep.
Multi-Cluster with App-of-Apps
Once you have more than one cluster, the hierarchy just grows another level at the top:
gitops-repo/
├── clusters/
│ ├── production-eu/
│ │ └── root.yaml
│ ├── production-us/
│ │ └── root.yaml
│ └── staging/
│ └── root.yaml
Each cluster gets its own root, and they share base config through Kustomize overlays so you’re not copy-pasting the same monitoring stack three times.
Adding a New Application
Here’s the payoff that makes the pattern worth the setup. Adding a new application is one file plus a git push:
# 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 here
# 3. Commit and push
git add .
git commit -m "Add new-service"
git push
ArgoCD spots the new Application manifest on the next sync and deploys it. No kubectl, no clicking through the UI, and a reviewable diff in Git that tells the next person exactly what changed and why.
ApplicationSets: The Next Level
If you find yourself writing near-identical Application manifests over and over, ApplicationSets generate them from a template:
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
Drop a directory under apps/ and you get an Application, no manifest to write. I reach for this when the apps are uniform. When they need individual tweaks, explicit Application files stay easier to read, so I mix both depending on the layer.
Common Mistakes
These are the ones that bit me, in roughly the order they bit me.
Mistake 1: Too Deep Nesting
Three levels is usually plenty. Go deeper and you spend more time tracing where a thing is defined than actually changing it:
✗ root → category → subcategory → team → service → component
✓ root → category → service
Mistake 2: Forgetting Finalizers
This one cost me an afternoon. Without finalizers, deleting an app-of-apps removes the parent but leaves its children running as orphans, no longer managed by anything:
metadata:
finalizers:
- resources-finalizer.argocd.argoproj.io
With the finalizer in place, deleting the parent cascades down and cleans up the children too.
Mistake 3: Circular Dependencies
Don’t let Application A depend on Application B that depends back on Application A. ArgoCD won’t untangle it for you, and you’ll watch both sit in a permanent progressing state.
Mistake 4: Everything in One Sync Wave
Cram everything into wave 0 and you’re back to race conditions, just with extra YAML. The whole point of the structure is ordered, health-gated rollout, so use the waves.
My Standard Structure
This is what I’ve landed on after enough iterations to stop second-guessing it:
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
The ordering guarantees the boring but important things:
- Namespaces exist before anything tries to deploy into them
- Platform tools are up before the apps that depend on them
- Databases are running before apps try to connect
The Bootstrap Problem Solved
This is where it pays off. In GitOps Disaster Recovery I argued that recovery should be a procedure you can run half-asleep. App-of-Apps gets you most of the way there:
# 1. New cluster
# 2. Install ArgoCD
# 3. Apply ONE file
kubectl apply -f clusters/production/root.yaml
# 4. Everything recreates
One command, and the cluster rebuilds itself from the repo. I’ve run this for real after deliberately wiping a homelab cluster, and watching a hundred resources come back in the right order from a single kubectl apply is the closest infrastructure gets to satisfying.
That’s the property I care about. With App-of-Apps the Git repo stops being a folder of files that happen to deploy things and becomes the actual specification of what should exist. When something drifts, ArgoCD pulls it back. When the cluster dies, the repo brings it back. I don’t have to trust my memory of how it was wired, which is exactly the kind of dependency I’m trying to design out of my systems.
