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:

  1. Manual creation: every new service needs a kubectl apply you have to run and remember
  2. No hierarchy: every Application is a peer, with no logical grouping
  3. Difficult onboarding: a new teammate (or future me) has no single place that lists what exists
  4. 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: Applications
  • 10: 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:

  1. Namespaces exist before anything tries to deploy into them
  2. Platform tools are up before the apps that depend on them
  3. 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.