Mijn homelab begon met één ArgoCD Application. Daarna een handvol. Toen ik mezelf voor de vijftiende keer kubectl apply -f zag draaien om weer een Application te registreren, wist ik dat ik precies had gebouwd wat ik wilde vermijden: handmatige stappen die ik moest onthouden, in een volgorde die ik moest onthouden, zonder enige administratie van wat er zou moeten bestaan.

Het App-of-Apps pattern lost dat op met één idee. Je maakt met de hand een enkele root Application, en die maakt de rest aan. Na een gewist cluster bouw ik het geheel terug met één kubectl apply. Die eigenschap is de hele reden dat ik het draai, en het is dezelfde reden dat ik überhaupt self-host: ik wil dat de repository de waarheid is, niet mijn geheugen.

Dit is inmiddels hoe ik elke GitOps repo structureer, en de vorm houdt stand of het nu drie apps in mijn homelab zijn of honderd op het werk.

Het probleem: application sprawl

Wanneer je ArgoCD voor het eerst adopteert, maak je Applications met de hand aan:

kubectl apply -f apps/frontend.yaml
kubectl apply -f apps/backend.yaml
kubectl apply -f apps/database.yaml
# herhaal voor elke service

Voor een paar services is dit prima. Daarna gaat het pijn doen:

  1. Handmatige creatie: elke nieuwe service vraagt om een kubectl apply die je moet draaien en onthouden
  2. Geen hiërarchie: elke Application is een peer, zonder logische groepering
  3. Moeilijke onboarding: een nieuwe collega (of de toekomstige ik) heeft geen enkele plek die opsomt wat er bestaat
  4. Bootstrap probleem: bouw een cluster opnieuw en je moet elke Application met de hand recreëren, in de juiste volgorde

Dit stoorde me altijd. Je adopteerde GitOps zodat je cluster matcht met een Git repo. Vervolgens maakte je de Applications zelf aan met imperatieve commando’s die nergens leven. De deployments zijn declaratief; het ding dat de deployments definieert is een stapel tribal knowledge.

De oplossing: App-of-Apps

App-of-Apps is één regel: Applications kunnen Applications aanmaken.

flowchart TD
    ROOT["Root Application<br/>(beheert alles)"]

    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"]

De root Application synct een directory vol Application manifests. Die Applications syncen op hun beurt de daadwerkelijke workloads. Zodra je het ziet is het logisch: een Application is gewoon nog een Kubernetes resource, dus natuurlijk kan ArgoCD Applications beheren zoals het Deployments beheert.

De repository structuur

Dit is hoe ik een GitOps repo indeel:

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/
│       └── (zelfde vorm als production)
├── base/
│   ├── monitoring/
│   │   ├── kustomization.yaml
│   │   └── (manifests)
│   └── (meer componenten)
└── apps/
    ├── frontend/
    │   ├── base/
    │   └── overlays/
    │       ├── staging/
    │       └── production/
    └── backend/
        └── (zelfde vorm)

De hiërarchie bouwen

Level 1: de root Application

De root is de enige Application die je ooit met de hand aanraakt. Alles daaronder volgt uit dit ene bestand:

# 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

Dit synct de clusters/production directory, die de volgende laag apps bevat.

Level 2: categorie Applications

Groepeer gerelateerde applicaties op wat ze zijn. Ik splits de mijne in infrastructure, platform en workloads, omdat die lagen een andere blast radius en een andere deploy-timing hebben:

# 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

Dit synct elk YAML bestand in de infrastructure directory, inclusief de individuele Applications erin.

Level 3: leaf Applications

De daadwerkelijke workload applicaties:

# 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: volgorde controleren

Sommige dingen moeten echt eerst. Een namespace voor de pods die erin leven. Een database voor de app die ermee verbindt. cert-manager voor alles dat een certificaat wil. Vuurt alles tegelijk af, dan krijg je een vlaag fouten die allemaal op de volgende retry oplossen, wat werkt maar er alarmerend uitziet en tijd kost.

ArgoCD sync waves geven je een deploy-volgorde zonder dat je het zelf hoeft te orkestreren:

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

Lagere nummers syncen eerst. Ik gebruik:

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

Health checks en dependencies

Sync waves doen er alleen toe omdat ArgoCD wacht tot één wave healthy is voordat het de volgende start:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: database
spec:
  # source en destination zoals hierboven
  syncPolicy:
    automated:
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
  # Health bepaalt wanneer "ready"

De database Application rapporteert healthy zodra zijn pods draaien. Alles in een latere wave dat ervan afhangt blijft wachten tot dat klopt. Je krijgt ordering gratis, afgeleid uit echte health in plaats van een hardcoded sleep.

Multi-cluster met App-of-Apps

Zodra je meer dan één cluster hebt, krijgt de hiërarchie er bovenaan gewoon een laag bij:

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

Elk cluster krijgt zijn eigen root, en ze delen base config via Kustomize overlays zodat je niet drie keer dezelfde monitoring stack zit te kopiëren.

Een nieuwe Application toevoegen

Hier zit de winst die het opzetten de moeite waard maakt. Een nieuwe applicatie toevoegen is één bestand plus een git push:

# 1. Maak het 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. Maak de applicatie manifests
mkdir -p apps/new-service/overlays/production
# voeg hier je Kustomization toe

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

ArgoCD ziet het nieuwe Application manifest bij de volgende sync en deployt het. Geen kubectl, geen geklik door de UI, en een reviewbare diff in Git die de volgende persoon precies vertelt wat er veranderde en waarom.

ApplicationSets: het volgende level

Betrap je jezelf erop dat je steeds bijna identieke Application manifests schrijft, dan genereren ApplicationSets ze uit een 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

Zet een directory onder apps/ en je krijgt een Application, zonder manifest te schrijven. Ik pak dit als de apps uniform zijn. Hebben ze individuele aanpassingen nodig, dan blijven expliciete Application bestanden makkelijker te lezen, dus ik meng beide afhankelijk van de laag.

Veelvoorkomende fouten

Dit zijn de fouten die mij beten, ongeveer in de volgorde waarin ze me beten.

Fout 1: te diepe nesting

Drie levels is meestal genoeg. Ga je dieper, dan ben je meer tijd kwijt aan uitzoeken waar iets gedefinieerd staat dan aan het daadwerkelijk wijzigen:

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

Fout 2: finalizers vergeten

Deze kostte me een middag. Zonder finalizers verwijdert het weghalen van een app-of-apps de parent, maar laat het zijn children draaiend achter als orphans, niet langer beheerd door iets:

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

Met de finalizer op zijn plek cascadeert het verwijderen van de parent naar beneden en ruimt het de children mee op.

Fout 3: circulaire dependencies

Laat Application A niet afhangen van Application B die weer terug afhangt van Application A. ArgoCD ontwart dat niet voor je, en je kijkt toe hoe beide in een permanente progressing state blijven hangen.

Fout 4: alles in één sync wave

Prop alles in wave 0 en je bent terug bij race conditions, alleen met extra YAML. Het hele punt van de structuur is een geordende, op health gebaseerde rollout, dus gebruik de waves.

Mijn standaard structuur

Dit is waar ik op uitkwam na genoeg iteraties om er niet meer aan te twijfelen:

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 beheert zichzelf
│   ├── 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

De volgorde garandeert de saaie maar belangrijke dingen:

  1. Namespaces bestaan voordat iets erin probeert te deployen
  2. Platform tools draaien voordat de apps die ervan afhangen
  3. Databases draaien voordat apps proberen te connecten

Het bootstrap probleem opgelost

Hier betaalt het zich uit. In GitOps Disaster Recovery betoogde ik dat recovery een procedure moet zijn die je half slapend kunt draaien. App-of-Apps brengt je een heel eind:

# 1. Nieuw cluster
# 2. Installeer ArgoCD
# 3. Apply ÉÉN bestand
kubectl apply -f clusters/production/root.yaml
# 4. Alles recreëert

Eén commando, en het cluster bouwt zichzelf terug uit de repo. Ik heb dit echt gedraaid na het bewust wissen van een homelab cluster, en honderd resources in de juiste volgorde zien terugkomen uit een enkele kubectl apply is zo bevredigend als infrastructuur maar wordt.

Dat is de eigenschap waar het me om gaat. Met App-of-Apps stopt de Git repo een map met bestanden te zijn die toevallig dingen deployen en wordt het de daadwerkelijke specificatie van wat zou moeten bestaan. Drift er iets, dan trekt ArgoCD het terug. Sterft het cluster, dan brengt de repo het terug. Ik hoef niet te vertrouwen op mijn herinnering aan hoe het bedraad was, en dat is precies het soort afhankelijkheid dat ik uit mijn systemen probeer te ontwerpen.