August 15, 2026 · 12 min read
ArgoCD App-of-Apps: What sync-wave and selfHeal Cannot See
Nodes were Ready and all 13 exporters were up, yet metrics never arrived. A root Application manages the cluster, but selfHeal only reverts what the manifests already declare.
When I changed a value by hand in the cluster, the declaration in the repository diverged from the actual state. No one knew they had diverged.
So I treated the repository as the source of truth. But even when the repository is the source of truth, nothing happens if one line is missing from the list. That was what happened when I attached a new node and received no metrics at all. The node was Ready, and the exporter pods were 13/13.
I manually applied one root Application
Argo CD reads a Git repository and applies Kubernetes resources. This approach, in which the declarations in the repository become the state of the cluster, is called GitOps.
The first step has to be applied by hand. The component that reads the repository does not exist yet.
I created one Application named root. It is not a service. It reads the argocd/applications directory and creates the Applications in it as children.
# clab-cluster/argocd/root-app.yaml
spec:
source:
path: argocd/applications # 이 디렉터리를 목록으로 읽는다
directory:
recurse: false # 바로 아래 파일만 본다
syncPolicy:
automated:
prune: true # Git에서 사라진 것은 클러스터에서도 지운다
selfHeal: true # 손으로 바꾼 값은 Git 쪽으로 되돌린다recurse: false can be confusing. It means that subdirectories are not scanned. root sees only the files in that directory, and each file specifies its own path separately.
prune deletes Applications removed from the repository from the cluster as well, and selfHeal restores manually changed values to the Git declaration.
This structure is called app-of-apps. Adding one file adds one service. The parent does not need to be changed.
I counted five things left outside GitOps
Manual work not managed by GitOps is documented in the README. I counted them again one by one this time and also checked them in the live cluster.
| What | Why it is outside | Live today |
|---|---|---|
First apply of root-app.yaml |
The component that reads the repository does not exist yet | Application root exists |
server.insecure in argocd-cmd-params-cm |
Traefik terminates TLS | "true" confirmed |
Traefik HelmChartConfig |
The default k3s Traefik rejects ExternalName | allowExternalNameServices: true confirmed for both providers |
| kube-prometheus-stack CRD | It hits the 256KiB annotation limit | Not checked |
| OAuth client secret | It is not committed to Git | Not checked |
The third line was interesting. The contents of that patch exist as a file in the repository. It is bootstrap/traefik-helmchartconfig.yaml, and the README says, “For reference; already applied to k3s.”
Having a declaration in the repository and having root apply it are different things. bootstrap/ is not the argocd/applications that root reads. The file exists, but the component that applies it is a person. Reading the repository and concluding, “This is managed by GitOps,” would be wrong.
The fourth line is not a one-time task. I excluded the CRDs from the chart with helm.skipCrds: true and added them separately with kubectl apply --server-side. The same command has to be run again each time the chart version is upgraded. This manual task returns with every upgrade; it is not just part of bootstrap.
If I rebuild the cluster from scratch, I have to remember these five things. Other than writing them in the README, I have done nothing to automate them.
I divided eleven child Applications into three sync-wave layers
There are eleven YAML files in argocd/applications/. There is no ApplicationSet. The list is committed by a person rather than generated.
sync-wave is a number that determines the application order. The next layer starts after the lower layer has finished.
| wave | Application | Source | Version | Namespace |
|---|---|---|---|---|
| 0 | cert-manager | Helm charts.jetstack.io | v1.16.2 |
cert-manager |
| 0 | kube-prometheus-stack | Helm prometheus-community | 66.3.1 |
monitoring |
| 0 | oauth2-proxy | Helm oauth2-proxy | 7.9.2 |
auth |
| 1 | cluster-issuer | Git infra/cluster-issuer |
main |
cert-manager |
| 1 | oauth-gateway | Git infra/oauth-gateway |
main |
monitoring |
| 1 | observability | Git infra/observability |
main |
monitoring |
| 2 | baguette | Git workloads/baguette |
main |
baguette |
| 2 | fp-router | Git workloads/fp-router |
main |
fp-router |
| 2 | hello-clab | Git workloads/hello-clab |
main |
hello-clab |
| 2 | postal | Git workloads/postal |
main |
postal |
| 2 | stalwart | Git workloads/stalwart |
main |
The reason there are three layers is the direction of the references. Wave 0 installs the charts. Wave 1 places resources on top of the CRDs created by those charts. The reason is written on the first line of observability.yaml: it is placed on the Operator CRDs installed by kube-prometheus-stack. Applying those resources first to a cluster without the CRDs fails.
Wave 2 contains the workloads. The certificates and authentication gateway have to be in place before they can be attached.
All eleven use prune: true, selfHeal: true, and CreateNamespace=true. kube-prometheus-stack and observability also have ServerSideApply=true. The reason is written in a comment on the observability side. The ConfigMap containing the dashboard JSON is large, so client-side application can hit the 256KiB limit of the last-applied-configuration annotation.
A single push reaches the cluster through three stages
The deployment flow documented in the README is short.
# clab-cluster/README.md:80-82
1. workloads/ 또는 argocd/applications/ 변경 → main 브랜치 push
2. Argo CD가 저장소를 polling(기본 3분) 또는 webhook으로 감지, 자동 sync
3. git revert로 되돌리면 클러스터 상태도 자동으로 되돌아온다The third line is why I use this structure. git revert does not delete a file; it makes the previous declaration into a new commit. When Argo CD syncs that commit, the cluster returns to the same state.
The way to revert is the same as the way to deploy. I do not memorize a separate rollback procedure.
Reconciliation runs every 3 minutes, while sync runs only when something changes
When I opened the status of root today, the two timestamps were different.
| Value | Time |
|---|---|
reconciledAt |
2026-08-15 04:48 |
| Time the last sync finished | 2026-08-07 14:33 |
The first value is updated every 3 minutes. Argo CD reads the repository again and compares it with the cluster. If they are the same, nothing happens. Sync occurs only when they differ, and the last such event was 8 days ago.
There is no timeout.reconciliation key in argocd-cm. Therefore, 3 minutes is Argo CD's default value. The README saying “default 3 minutes” means that the setting was not added, not that it was configured.
The same ConfigMap contains resource.exclusions, whose list includes Endpoints and EndpointSlice. Argo CD does not look at these at all. A pod being removed from an endpoint is not Argo CD's concern; a person checks that. The reason the Application remained Healthy when the API was removed from traffic in Part 23 is in this one line.
The exporter was healthy, and one line was missing from the list
I attached a new worker node, aks-node-02. Its metrics did not appear in Grafana. aks-node-01 appeared normally.
This incident occurred in another cluster, not the clab cluster. That cluster uses an ApplicationSet. The structure differs, but the problem of how the list is managed is the same.
I only read the configuration. I changed nothing. This was the order in which I checked it.
| What I checked | aks-node-01 | aks-node-02 |
|---|---|---|
| Node status | Ready | Ready (joined 26 hours ago) |
| exporter DaemonSet | 13/13 Ready | Pod ovh-node-exporter-sm8dp 2/2 Running |
| PodMonitor | vg-aks-node-01-node-exporter exists |
None |
| ArgoCD Application | vg-aks-node-01 exists |
None |
| Generator input file | values/nodes/aks-node-01.yaml exists |
None |
| Prometheus series | Exists | None |
The two lines at the top were normal. Both the node and the pod were alive. Starting at the third line, nothing existed.
A pod being ready and Prometheus scraping that pod are different facts. To scrape it, a scrape target has to be declared, and that declaration is the PodMonitor.
The generator used a manually written list, not a glob
I traced upward to find out why there was no PodMonitor. There was no Application to create it, and the ApplicationSet that creates Applications did not have that node in its inputs.
The ApplicationSet git-files generator was not a pattern. It was a list containing thirteen paths written out as-is.
# aks-cluster/k8s/argocd/apps/10-observability.yaml:351-377
values/nodes/aks-node-01.yaml
values/nodes/ovh-node-1.yaml … ovh-node-9.yaml
values/nodes/ws-node-1.yaml
values/nodes/la-node-1.yaml
values/nodes/cherry-node-1.yamlaks-node-02.yaml was missing. Even if a new file is created in the repository, no Application is created unless its path is added to this list.
The file itself was missing too. values/nodes/ contained only aks-node-01.yaml, and there was no new node entry in catalog.yaml. Fixing only the list would not have been enough.
I finished by querying the central Prometheus directly. In up{node=~"aks-node-01|aks-node-02"}, only the series for 01 appeared. This matched what I saw in Grafana.
The cause was neither the exporter nor the network. Two files had been omitted from the onboarding procedure. At the time, approval from the user was still pending, so I investigated but did not fix it.
selfHeal restores drift but cannot find omissions
The two structures catch different things.
| State different from the declaration | State where the declaration itself is missing | |
|---|---|---|
| What catches it | selfHeal |
Nothing |
| How it appears | Argo CD restores it | Silently |
| How long it takes | Within 3 minutes of polling | Until someone notices |
selfHeal protects only what it knows about. An undeclared resource is not something it is responsible for protecting. Absence is not drift.
In the aks-node-02 incident, every layer was green. The Application was Synced and Healthy. Only one node, absent from the list, was missing.
When automation holds its list by hand, that list becomes a person's memory. Memory omits things.
The root in the clab cluster reads a directory. Adding a file updates the list automatically. The other cluster, where I attached the new node, has paths written by hand. It is the same GitOps, but in one case creating a file is enough, while in the other two places have to be changed.
There were two roots, and one list had no ordering
When I queried the cluster for Applications today, there were 23. That was more than the eleven I counted earlier.
There was another parent besides root: clab-app-root. The two parents had the same shape. Only the repositories they read were different.
root |
clab-app-root |
|
|---|---|---|
| Repository | clab-cluster | clab-app |
| Path read | argocd/applications |
argocd/applications |
recurse |
false | false |
| Child count | 11 | 10 |
prune·selfHeal |
Both true | Both true |
| sync-wave | 0, 1, or 2 for each child | None |
11 plus 10 is 21, and adding the two parents makes 23. The two parents themselves are Applications, so they also appear in the list. All 23 were Synced and Healthy today.
The last line is the difference between the two lists. The eleven children of clab-cluster have sync-wave values, while none of the ten children of clab-app do.
There is a reason this is not necessary. The clab-cluster list contains certificates, the authentication gateway, and observability tools. They depend on one another. The clab-app list contains product services, and they do not depend on one another. The backend does not need to come up before the console.
clab-app has another characteristic instead. Its ten entries point to seven repositories.
| Application | Repository | Path |
|---|---|---|
| discourse, plane | clab-app | workloads/… |
| justsend-backend, justsend-console, justsend-ops | justsend-platform | backend, console, ops |
| justsend-render | justsend-render | . |
| justsend-share-web | justsend-share-web | . |
| justsend-web | justsend-web | . |
| posy-weather-web | clab-weather-web | deploy/k8s |
| test-agents | test-agents | . |
There are four path: . entries. This is a layout in which manifests are placed at the repository root and one repository corresponds to one service. When adding a service, this is where the choice arises between creating a new repository and creating a directory inside an existing repository; here, both patterns are mixed.
Either way, adding one file to the list is the same. What differs is what comes next. With seven repositories, there are also seven places to revert with git revert. I have to remember which service is in which repository.
The copy on my disk was not the source of truth
While verifying this manuscript, I got two different lists twice.
There were two copies of clab-app on disk. One had justsend-ocr.yaml, justsend-ocr-normalizer.yaml, and justsend-web-app.yaml, while the other had discourse.yaml, justsend-ops.yaml, and justsend-render.yaml. They differed by three files each.
# 같은 저장소의 두 사본
clab/clab-app HEAD e810581 (2026-08-04)
justsend-platform/clab-app HEAD 1fbd5d2 (2026-08-14)
origin/main 1fbd5d2The older copy was ten days behind. When I queried the cluster, discourse, justsend-ops, and justsend-render existed, while justsend-ocr did not. The newer copy was correct.
If I had written this manuscript using only the old copy, I would have recorded three services as absent and three absent services as present. Since the file list is the service list, a wrong copy makes the entire list wrong.
The source of truth protected by selfHeal is main in the remote repository. It is not the checkout on my laptop. Argo CD looks at what it fetched, while a person looks at the directory they opened. There is no signal anywhere when the two diverge.
So when I count a list, I check three places together: the remote main, the Applications in the cluster, and the directory I am reading. I wrote the numbers in this manuscript only after all three matched.