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-stagingandguestbookall Synced. - Get the new files:
git pull. You'll havebootstrap/root.yaml. - Where commands run: your own computer's terminal, in
gitops-practice, logged in withargocd 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:
argocd app list -o name
ls argocd
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
# 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/, notargocd/. 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:
for f in argocd/*.yaml; do kubectl diff -f $f >/dev/null && echo "$f: matches the cluster" || echo "$f: differs from the cluster"; done
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:
kubectl apply -f bootstrap/root.yaml
argocd app get root
argocd app list -o name
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:
kubectl get application bookshop -n argocd -o jsonpath='{.metadata.annotations.argocd\.argoproj\.io/tracking-id}'
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.
argocd app set bookshop-staging --sync-policy none
kubectl get application bookshop-staging -n argocd -o jsonpath='{.spec.syncPolicy}'
{"syncOptions":["CreateNamespace=true"]}
Automated sync gone. And then, without anyone doing anything:
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:
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:
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
git rm argocd/bookshop-preview.yaml
Merged, and the root's prune removed the Application:
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?
kubectl get pods,services -n bookshop-preview
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:
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:
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):
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
argocd app history root
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-controllerpod 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=falseto production Applications, so a bad merge toargocd/can't delete a production Application at all. - Projects limit what each app can do. Every Application here uses the
defaultproject, 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
| To | Write or run |
|---|---|
| A root Application | An Application with path: argocd and destination.namespace: argocd |
| Start the chain, once | kubectl apply -f bootstrap/root.yaml |
| Check files match before adopting | kubectl diff -f argocd/FILE.yaml |
| See which Applications the root manages | argocd app get root |
| Who owns an Application | Its argocd.argoproj.io/tracking-id annotation |
| Add an app | Add a file to argocd/, by pull request |
| Remove an app | Delete its file, by pull request |
| Delete an app's resources with it | finalizers: [resources-finalizer.argocd.argoproj.io] |
| Clean up orphans by hand | kubectl delete namespace NAMESPACE |
| Change an Application | Edit 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
What problem does app-of-apps solve?
Why is the root Application in bootstrap/, not argocd/?
The root adopted three existing Applications. What happened to the shops?
argocd app set changed bookshop-staging. What happened next?
The preview's file was deleted, with no finalizer. What was left?
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 toargocd/by mistake, and runargocd 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-aandpreview-b. How many Applications does one file make?