Argo CD and Helm · Lesson 8 of 11 · about 25 minutes

App-of-apps: one repository, every app

Three Applications, all applied by hand, with nothing watching their files. One root Application adopts them from Git in a second, and undoes a hand change to one of them in another. Then a new shop by adding one file, and the catch: deleting an Application leaves everything it deployed still running, unless a finalizer says otherwise.

By the end you'll know

  • What app-of-apps is, and why the Applications themselves belong in Git
  • How a root Application adopts Applications that already exist
  • Why the root is the one thing you apply by hand
  • How adding or deleting a file adds or removes a whole app
  • What happens to an app's resources when its Application is deleted, and what a finalizer changes

Before you start

  • You need: Lessons 1 to 7 done, with bookshop, bookshop-staging and guestbook all Synced.
  • Get the new files: git pull. You'll have bootstrap/root.yaml.
  • Where commands run: your own computer's terminal, in gitops-practice, logged in with argocd login.

The short version

  • The problem. Every Application so far was applied by hand. Their files are in Git, but nothing watches them, so a CLI change drifts and stays drifted, as Lesson 4 found.
  • The fix. A root Application whose folder is argocd/. It adopted the three existing Applications in a second, and put back a hand change to one of them a second later.
  • Apps by file. Adding a file to argocd/ created a whole new shop. Deleting it removed the Application.
  • The catch. Removing the Application left its pod and Service running. With a finalizer on it, the next removal took everything it had deployed too.

Who watches the Applications?

Since Lesson 3, every app has been an Application in argocd/, applied with kubectl apply:

BASH
argocd app list -o name
ls argocd
OUTPUT
argocd/bookshop
argocd/bookshop-staging
argocd/guestbook

bookshop-staging.yaml
bookshop.yaml
guestbook.yaml

Argo CD keeps the apps matching Git. But the Applications, which say what each app is, where it comes from and how it syncs, are only in the cluster because someone applied them. Lesson 4 showed the cost: argocd app set changed an Application, its file in Git said otherwise, and nothing noticed. It's the original GitOps problem again, one level up.

The answer is to treat Applications like any other Kubernetes object, which they are, and have Argo CD manage them from Git. App-of-apps: one Application whose folder is full of Applications. Think of a building's directory board in the lobby. Each office looks after its own rooms. The board says which offices exist and where they are. Add a line to the board, and a new office opens; take it off, and it's gone. The root Application is the board.

The root Application

YAML
# bootstrap/root.yaml
# The root Application: it manages every Application in the argocd/ folder.
# Apply it once, by hand: kubectl apply -f bootstrap/root.yaml
# From then on, adding, changing or removing a file in argocd/ is how apps change.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/Kwamib/gitops-practice.git
    targetRevision: main
    path: argocd
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

An ordinary Application. Its path is argocd/, where the Application files are, and its destination is the argocd namespace, where Applications live. Two details:

  • It's in bootstrap/, not argocd/. If the root were in its own folder, it would manage itself, and deleting its file would delete the thing that's reading the file. Keeping it apart is simpler.
  • It's the one thing applied by hand, once. Something has to start the chain. That's called bootstrapping, and it's why the folder has that name.

Before adopting anything, a check that the files in Git match what's running, so the root won't change them:

BASH
for f in argocd/*.yaml; do kubectl diff -f $f >/dev/null && echo "$f: matches the cluster" || echo "$f: differs from the cluster"; done
OUTPUT
argocd/bookshop-staging.yaml: matches the cluster
argocd/bookshop.yaml: matches the cluster
argocd/guestbook.yaml: matches the cluster

All three match. Through a pull request, then the one hand-applied command:

BASH
kubectl apply -f bootstrap/root.yaml
argocd app get root
argocd app list -o name
OUTPUT
merged: db55181 App-of-apps: a root Application for everything in argocd/ (#25)
application.argoproj.io/root created
root: synced to db55181, Healthy, after 1s

GROUP        KIND         NAMESPACE  NAME              STATUS  HEALTH  HOOK  MESSAGE
argoproj.io  Application  argocd     bookshop-staging  Synced                application.argoproj.io/bookshop-staging configured
argoproj.io  Application  argocd     guestbook         Synced                application.argoproj.io/guestbook configured
argoproj.io  Application  argocd     bookshop          Synced                application.argoproj.io/bookshop configured

argocd/bookshop
argocd/bookshop-staging
argocd/guestbook
argocd/root

The root's resources are Applications. Where bookshop's table lists a Deployment and a Service, the root's lists the three Applications, each Synced. They were configured, not created: they already existed, and the root adopted them, marking each as its own:

BASH
kubectl get application bookshop -n argocd -o jsonpath='{.metadata.annotations.argocd\.argoproj\.io/tracking-id}'
OUTPUT
root:argoproj.io/Application:argocd/bookshop

The same tracking annotation as Lesson 2's, now saying bookshop belongs to root. Nothing about the shops changed: the pods didn't roll, because the Applications were identical to their files.

Lesson 4's catch, caught

The test: change an Application by hand, the way Lesson 4 did.

BASH
argocd app set bookshop-staging --sync-policy none
kubectl get application bookshop-staging -n argocd -o jsonpath='{.spec.syncPolicy}'
OUTPUT
{"syncOptions":["CreateNamespace=true"]}

Automated sync gone. And then, without anyone doing anything:

OUTPUT
put back after 1s
{"automated":{"prune":true,"selfHeal":true},"syncOptions":["CreateNamespace=true"]}

One second. The root saw bookshop-staging no longer matching its file, and self-heal put it back, exactly as bookshop itself did with kubectl scale in Lesson 1. Now argocd app set doesn't stick on any Application the root manages. To change one, you change its file, which is the point.

A new app, by adding a file

A preview copy of the shop, to show a change to someone before it goes to staging. A file, argocd/bookshop-preview.yaml, from staging's:

OUTPUT
1c1
< # Staging: the same chart, with staging's values. Changes land here first (Lesson 7).
---
> # Preview: a short-lived copy of staging, to show a change to someone (Lesson 8).
5c5
<   name: bookshop-staging
---
>   name: bookshop-preview
20c20
<     namespace: bookshop-staging
---
>     namespace: bookshop-preview

Merged. No kubectl apply:

OUTPUT
merged: 802e6b7 Bookshop: a preview copy, for showing a change (#26)
root: synced to 802e6b7, Healthy, after 2s
bookshop-preview: synced to 802e6b7, Healthy, after 1s

argocd/bookshop
argocd/bookshop-preview
argocd/bookshop-staging
argocd/guestbook
argocd/root

NAME                        READY   STATUS    RESTARTS   AGE
bookshop-5b884bc965-sp9qq   1/1     Running   0          2s

A whole new shop, from one file in a pull request. The root saw a new Application in its folder and created it; the new Application then deployed its chart. Two levels, one merge.

Removing it, by deleting the file

BASH
git rm argocd/bookshop-preview.yaml

Merged, and the root's prune removed the Application:

OUTPUT
merged: cee4124 Bookshop: remove the preview copy (#27)
root: synced to cee4124, Healthy, after 3s

argocd/bookshop
argocd/bookshop-staging
argocd/guestbook
argocd/root

Gone from the list. But the shop it deployed?

BASH
kubectl get pods,services -n bookshop-preview
OUTPUT
NAME                            READY   STATUS    RESTARTS   AGE
pod/bookshop-5b884bc965-sp9qq   1/1     Running   0          19s

NAME               TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
service/bookshop   ClusterIP   10.96.58.192   <none>        80/TCP    19s

Still running. The Application is deleted, and everything it deployed is left behind, with nothing managing it. By default, deleting an Application deletes only the Application, not its resources. That's deliberately cautious: an Application deleted by mistake, by a bad pull request or a renamed folder, doesn't take a production app down with it. The cost is orphans like these, running and forgotten, which here a person cleans up:

BASH
kubectl delete namespace bookshop-preview

The finalizer

When deleting an Application should delete its resources, as for a short-lived preview, it says so with a finalizer:

YAML
metadata:
  name: bookshop-preview
  namespace: argocd
  finalizers:
    # Deleting this Application deletes everything it deployed, too.
    - resources-finalizer.argocd.argoproj.io

A finalizer is a Kubernetes note on an object that says "before this is really deleted, something has to finish a job". Here, the job is Argo CD deleting every resource the Application deployed. The preview again, with it (#28), running, then its file deleted again (#29):

OUTPUT
merged: a6f6302 Bookshop: remove the preview copy, and what it deployed (#29)
root: synced to a6f6302, Healthy, after 7s

argocd/bookshop
argocd/bookshop-staging
argocd/guestbook
argocd/root

No resources found in bookshop-preview namespace.

The Application and everything it deployed, gone. (The namespace itself is left, empty: CreateNamespace made it, and Argo CD doesn't delete namespaces it didn't track.) So which do you want?

  • Short-lived apps, previews and experiments: a finalizer, so removing the file cleans up completely.
  • Production: often no finalizer, so a mistaken delete leaves the app running while someone puts the file back. Some teams go further and stop the root pruning Applications at all.

Every app, one history

BASH
argocd app history root
OUTPUT
ID      DATE                           REVISION
0       2026-11-04 13:41:14 -0500 EST  main (db55181)
1       2026-11-04 13:41:25 -0500 EST  main (802e6b7)
2       2026-11-04 13:41:38 -0500 EST  main (cee4124)
3       2026-11-04 13:42:21 -0500 EST  main (8519fc0)
4       2026-11-04 13:42:35 -0500 EST  main (a6f6302)

The root's history is the history of which apps exist: adopted, preview added, removed, added with a finalizer, removed again. Five commits, five pull requests. From now on, the whole cluster's contents, what runs and how it's set up, can be read from one repository.

In real systems

  • One root per cluster. The usual shape is a root Application per cluster, bootstrapped once, often by Terraform when the cluster is built. Everything else, including Argo CD's own settings, comes from Git after that.
  • ApplicationSets for many similar apps. When there are dozens of near-identical Applications, like a preview per pull request or the same app in every cluster, an ApplicationSet generates them from a template. It's the applicationset-controller pod from Lesson 1. App-of-apps is the simpler first step.
  • Protect production from deletes. Teams often turn off pruning on the root, or add Prune=false to production Applications, so a bad merge to argocd/ can't delete a production Application at all.
  • Projects limit what each app can do. Every Application here uses the default project, which allows anything. Real setups give each team an AppProject that limits which repositories it can deploy from and which namespaces it can deploy to.

Spot the problem

Work out what's wrong before you read the explanation.

The tidy-up that deleted production. A team reorganizes their GitOps repository: argocd/ becomes apps/argocd/, in one pull request that moves every file. It looks like a harmless rename, and it's merged. Within seconds, Argo CD reports every Application deleted, and the production apps go down. What happened? To the root, the folder it watches became empty. It still points at argocd/, which no longer has any files, so prune removed every Application in it. And because production's Applications had finalizers, each deletion took its whole app with it. Two separate safeguards would each have stopped this: the root's path updated in the same pull request (or the root with pruning off), and no finalizer on production Applications. The habit worth keeping: before merging a change to a folder Argo CD watches, render what Argo CD will see, or check argocd app diff root.

Keep this

ToWrite or run
A root ApplicationAn Application with path: argocd and destination.namespace: argocd
Start the chain, oncekubectl apply -f bootstrap/root.yaml
Check files match before adoptingkubectl diff -f argocd/FILE.yaml
See which Applications the root managesargocd app get root
Who owns an ApplicationIts argocd.argoproj.io/tracking-id annotation
Add an appAdd a file to argocd/, by pull request
Remove an appDelete its file, by pull request
Delete an app's resources with itfinalizers: [resources-finalizer.argocd.argoproj.io]
Clean up orphans by handkubectl delete namespace NAMESPACE
Change an ApplicationEdit its file; argocd app set is undone in a second

Where you are now

Everything Argo CD runs is now in Git, including the Applications: one root, applied once by hand, manages the rest. Adding a file adds an app, deleting one removes it, and a finalizer decides whether its resources go too. Lesson 9 goes the other way through history: something bad reaches production, and you roll it back, with git revert and with Argo CD's own rollback, and see why one of them is the right habit.

Check yourself

  1. What problem does app-of-apps solve?

  2. Why is the root Application in bootstrap/, not argocd/?

  3. The root adopted three existing Applications. What happened to the shops?

  4. argocd app set changed bookshop-staging. What happened next?

  5. The preview's file was deleted, with no finalizer. What was left?

  6. When might you leave the finalizer off?

Key terms

  • App-of-apps: an Application whose folder holds other Applications, so they're managed from Git too.
  • Root Application: the Application at the top, applied once by hand.
  • Bootstrapping: the one manual step that starts everything else.
  • Adopt: a root taking over Applications that already exist, by marking them as its own.
  • Orphan: a resource still running after the Application that deployed it was deleted.
  • Finalizer: a note on a Kubernetes object saying a job must finish before it's really deleted.
  • ApplicationSet: an Argo CD object that generates many similar Applications from a template.
  • AppProject: an Argo CD object that limits what a group of Applications may deploy, and where.

Go deeper

  • The Argo CD documentation: Cluster bootstrapping (opens in a new tab), and App deletion (opens in a new tab).
  • Try: add bootstrap/root.yaml's own file to argocd/ by mistake, and run argocd app diff root. What would the root do? Remove it before merging.
  • Try: make the preview an ApplicationSet instead, with a list generator of two names, preview-a and preview-b. How many Applications does one file make?