Argo CD
CGOA teaches the four GitOps principles as an abstract specification, deliberately unattached to any one tool. Argo CD is where that specification becomes a Kubernetes controller you install, configure, and page someone about — the single most widely run implementation of the pull model, and 34% of the CAPA blueprint on its own. It runs inside your cluster, watches a Git repository for the manifests you say should be running, and never stops comparing that desired state against what is actually live — surfacing the diff in a web UI and CLI and, if you let it, closing the gap automatically. This page goes past the principle and into the machinery: the controllers behind the loop, the Application resource you actually write, how sync status and health status answer two different questions, sync waves and hooks, the two patterns for scaling past a handful of apps, the CLI, and the failure modes that catch people who only read the happy path.
Imagine a huge LEGO city a hundred kids build together, and one very patient robot whose only job is walking the city with an instruction booklet everyone can read on a shelf. Every few minutes it checks: "page 12 says a red tower — yep, matches." "Page 13 says a bridge — someone took the bridge apart!" It doesn't get mad, doesn't ask who did it, just quietly rebuilds the bridge to match the booklet, and keeps walking. If you want the city to change, you don't sneak in and move a brick — you edit the booklet, and the robot does the rest on its very next lap. That's Argo CD: the booklet is a Git repository, the city is your cluster, and the robot never stops walking.
Architecture: the controllers behind the loop
☺ Like you're 10: One piece fetches the booklet page, one piece checks it against the real city, and one piece answers the door when you want to look.
A standard install lays down a handful of Deployments in their own argocd namespace, and each one owns a narrow slice of the loop — which makes debugging fast once you know which log to open. argocd-repo-server clones your Git repository and renders whatever it finds — plain YAML, a Kustomize overlay run through kustomize build, or a Helm chart run through helm template — into a flat list of Kubernetes manifests, then caches the result. argocd-application-controller is the reconciler itself, and it ships as a StatefulSet, not a Deployment — worth remembering, because kubectl get deploy -n argocd will quietly omit the one workload that matters most. It watches every Application object, diffs the repo-server's rendered manifests against the live cluster, computes health, and — depending on sync policy — either applies the difference or just reports it. argocd-server is the single API (gRPC/REST) backing both the web UI and the argocd CLI, and it's what authenticates you, whether through the built-in admin account or SSO via the optional argocd-dex-server bridge to an OIDC provider. redis caches rendered manifests and live-resource trees so the controller isn't re-listing the whole cluster on every pass, and a separate argocd-applicationset-controller handles the templating-at-scale pattern covered further down this page.
Argo CD itself began inside Intuit, via its 2018 acquisition of Applatix, and was open-sourced the same year. Alongside its sibling projects it became a CNCF incubating project in 2020 and graduated in December 2022 — The Argo Ecosystem covers what that family relationship does and doesn't mean operationally, and is worth reading before this page if the umbrella-versus-product distinction isn't already clear.
Argo CD keeps no external database. Everything it knows lives in Kubernetes itself — Application, AppProject and ApplicationSet custom resources, plus Secrets holding repo and cluster credentials. Redis is purely a cache: delete the Redis pod and Argo CD is slow for a minute while it recomputes, but nothing is ever lost. That's also why removing cluster credentials from CI is the underrated half of the GitOps pitch — a push-based pipeline needs a token that can create, update and delete resources in production, sitting in a system that also runs untrusted pull-request code from every contributor. A pull-based reconciler needs no inbound credential at all.
The Application resource — the thing you actually write
☺ Like you're 10: One file says which Git folder to watch, which cluster and namespace it belongs in, and whether Argo CD may act on its own or has to wait for a person to say go.
An Application is the whole interface — a single custom resource, living in Argo CD's own namespace, that names a Git source, a destination, and a sync policy. Everything else on this page is elaboration on one of those three fields.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: mission-log
namespace: argocd # Applications live in Argo CD's own namespace
finalizers:
- resources-finalizer.argocd.argoproj.io # cascade: deleting this Application also prunes what it created
spec:
project: mission-control # NOT "default" in anything that matters — see AppProject below
source:
repoURL: https://github.com/kubestronaut/mission-log-manifests.git
targetRevision: main # a branch, a tag, or an exact commit SHA
path: overlays/prod
destination:
server: https://kubernetes.default.svc # or `name:` for a cluster registered by name, not URL
namespace: mission-log
syncPolicy:
automated:
prune: true # delete resources removed from Git — OFF by default even under automated
selfHeal: true # revert live drift back to Git — also OFF by default
syncOptions:
- CreateNamespace=true
retry:
limit: 5
backoff: { duration: 5s, factor: 2, maxDuration: 3m }
ignoreDifferences:
- group: apps
kind: Deployment
jsonPointers:
- /spec/replicas # an HPA owns this field now; stop reporting it as driftTwo defaults surprise almost everyone the first time. prune and selfHeal are independent booleans that both default to false — even once automated is present — so an "automated" Application with neither flag set will apply new and changed resources on its own yet silently leave a deleted-from-Git resource running forever and never correct a manual kubectl edit. All three pieces have to be set deliberately for the loop this page describes to actually close. And source.targetRevision pinned to an exact commit SHA rather than a branch is how you get a fully immutable, auditable deploy; newer Argo CD releases also support a sources: list in place of a single source:, letting one Application combine a Helm chart from one repository with values from another — check the version your platform runs before assuming it's available.
Sync status vs. health status — two different questions
☺ Like you're 10: One badge asks "does it match the booklet?" The other asks "is it actually working?" — and a page can pass one and fail the other at the same time.
Two states come out of every reconciliation, and they're easy to conflate because they usually agree — until the moment they don't, which is exactly when the distinction earns its keep. Sync status asks whether live state matches Git. Health status asks whether what's running is actually working. A Deployment can be perfectly Synced — every field matches Git exactly — while its pods crash-loop on a bad readiness probe, which reads as Degraded: Argo CD faithfully deployed exactly what you asked for, and what you asked for was broken.
| Status family | Value | Means |
|---|---|---|
| Sync | Synced | Live state matches the rendered desired state at targetRevision. |
OutOfSync | A difference exists — a new commit not yet applied, or the cluster has drifted. | |
Unknown | Argo CD couldn't compare — usually a repo it can't reach or manifests it can't render. | |
| Health | Healthy | Every resource reports ready, per its health check. |
Progressing | Still rolling out — not a failure. Deployments sit here mid-update. | |
Degraded | A resource failed — crash-looping pods, a failed Job, unavailable replicas. | |
Suspended | Intentionally paused — a suspended CronJob, or a paused canary awaiting promotion. | |
Missing | Declared in Git but absent from the cluster. | |
Unknown | No health check applies to this kind, or the comparison itself failed. |
Argo CD ships built-in health logic for the standard kinds plus a few Kubernetes-ecosystem extras — Ingress, PVC, Argo Rollouts' own Rollout kind. For a custom resource it doesn't recognize out of the box, you supply a small Lua script under resource.customizations in the argocd-cm ConfigMap — skip that step and a genuinely broken CRD-backed workload just sits at Healthy or Progressing forever, telling you nothing.
Ordering a sync: waves and hooks
☺ Like you're 10: You can force some pieces to build before others, and you can run a one-off job right before or right after the main build finishes.
Most objects in a sync apply together with no ordering guarantee, but sometimes order genuinely matters — a namespace or a CRD has to exist before anything using it does. The argocd.argoproj.io/sync-wave annotation (default "0", any integer allowed) groups resources into waves applied lowest-first, and — critically — the controller waits for a wave to reach Healthy before starting the next one. That means a wave boundary is a health gate, not just an apply gate: a resource with no health check races ahead, and one that never goes healthy stalls the whole sync until it times out.
Hooks are the finer-grained tool, for a one-off action rather than a whole wave of resources. A resource annotated argocd.argoproj.io/hook: PreSync (or Sync, PostSync, SyncFail) runs at that specific point in the sync instead of as a normally-tracked resource, and hook-delete-policy: HookSucceeded,BeforeHookCreation controls whether the hook Job cleans itself up or piles up in the namespace after every deploy.
apiVersion: v1
kind: Namespace
metadata:
name: mission-log
annotations:
argocd.argoproj.io/sync-wave: "-2" # namespaces and CRDs go early
---
apiVersion: batch/v1
kind: Job
metadata:
name: telemetry-schema-migrate
annotations:
argocd.argoproj.io/hook: PreSync # runs before the main apply, not as a tracked resource
argocd.argoproj.io/hook-delete-policy: HookSucceeded # tidy up once it passes
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: ghcr.io/kubestronaut/telemetry-migrations:1.2.0
command: ["/app/migrate", "up"]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mission-log
annotations:
argocd.argoproj.io/sync-wave: "0" # after the namespace, after the migration hookargocd app rollback mission-log 7 re-applies a prior recorded revision's manifests directly. It does not create a Git commit and does not move main's HEAD. If selfHeal is on, the very next reconciliation cycle sees live state diverging from Git's still-current HEAD — the exact commit you were trying to escape — and dutifully re-applies it, undoing your rollback within minutes. The GitOps-idiomatic rollback is git revert on the manifest repository, letting the normal reconciliation loop carry the fix forward the same way it carries everything else. Reserve argocd app rollback for a genuine emergency, on an Application you've first switched to manual sync.
Scaling out: App-of-Apps and ApplicationSet
☺ Like you're 10: One booklet can point at a whole shelf of other booklets, and one template can stamp out a slightly different booklet per cluster automatically.
One team, one service, one cluster — a single hand-written Application is plenty. A whole GitOps rollout across dozens or hundreds of apps needs one of two composition patterns instead of hand-writing every one.
The App-of-Apps pattern is the simpler of the two: a root Application whose source.path points not at Kubernetes manifests but at a directory of other Application manifests. Sync the root, and Argo CD creates every child Application it finds there, each of which then reconciles independently against its own source and destination. It's the standard way to bootstrap a whole platform's baseline — ingress, Prometheus, Kyverno, and every team's own root — from a single kubectl apply, and it's also how many teams manage Argo CD's own configuration: a root Application pointing at Argo CD's own Helm values, so upgrading Argo CD is itself a GitOps-managed sync.
The ApplicationSet controller solves a related but distinct problem: not "many different apps," but "the same app, templated across many targets." A generator produces a list of parameter sets — List (a literal set you write out), Cluster (one instance per cluster Argo CD has registered), Git (one instance per directory or file matched in a repo), Matrix (the cartesian product of two other generators — every app times every cluster), or Pull Request (one ephemeral instance per open PR, for preview environments) — and a template block turns each parameter set into a real Application, the same relationship a Helm chart has to its rendered manifests, one level up the stack.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: mission-log-environments
namespace: argocd
spec:
generators:
- list:
elements:
- env: dev
cluster: https://dev.k8s.mission.internal
- env: staging
cluster: https://staging.k8s.mission.internal
- env: prod
cluster: https://prod.k8s.mission.internal
template:
metadata:
name: 'mission-log-{{env}}'
spec:
project: mission-control
source:
repoURL: https://github.com/kubestronaut/mission-log-manifests.git
targetRevision: main
path: 'overlays/{{env}}'
destination:
server: '{{cluster}}'
namespace: mission-log
syncPolicy:
automated: { prune: true, selfHeal: true }That one ApplicationSet replaces three hand-maintained Application files that would otherwise drift the moment someone edits one and forgets the other two. Reach for App-of-Apps when the list is small and hand-curated — a platform's own baseline components; reach for ApplicationSet when it should be generated from a pattern instead — every team's namespace, or every add-on across every cluster.
Reconciliation patterns: where CGOA's Patterns domain gets concrete
☺ Like you're 10: The robot can either keep glancing at the booklet on its own schedule or wait for someone to shout "it changed" — and the robot itself can live inside one city or sit outside watching several at once.
CGOA's Patterns domain names three architectural axes in the abstract — trigger, reconciler placement, state store scope — and Argo CD is a concrete, examinable answer to all three. Trigger: by default the repo-server polls Git roughly every three minutes; a configured webhook makes reconciliation near-instant instead, but the poll keeps running underneath regardless, because event-driven is a latency optimization layered on top of pull, never a replacement for it — a system that only reconciled on webhooks would silently stop noticing drift the moment a webhook got dropped. Reconciler placement: the argocd-application-controller itself always runs inside exactly one "hub" cluster, but through argocd cluster add it can register and reconcile many destination clusters from that single hub — so Argo CD is simultaneously an in-cluster reconciler relative to the cluster it's installed in, and an external reconciler relative to every other cluster on its list, holding credentials for every one of them. State store scope: nothing in Argo CD's architecture forces monorepo or split-repo; AppProject.spec.sourceRepos is the actual control on how many repositories a given tenant may pull from, which makes the store-scope decision a tenancy decision, not a technical one.
"I assumed 'in-cluster reconciler' meant Argo CD only ever touched the cluster it lived in — then I watched it apply a Deployment to a cluster three regions away that I'd registered with one argocd cluster add command. It's in-cluster architecturally, in the sense that its own controller pod never leaves one place. Operationally, from every other cluster's point of view, it's an outside actor with a credential — which is exactly the fleet-visibility-versus-blast-radius trade CGOA's Patterns domain is actually testing."
Day-to-day commands
☺ Like you're 10: Log in, point it at a repo and a cluster, and from there almost everything is list, look, diff, sync.
# first login — install generates a random admin password, stored as a Secret
$ kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d
$ argocd login argocd.mission.internal --username admin # or --sso once Dex/OIDC is wired up
$ argocd repo add https://github.com/kubestronaut/mission-log-manifests.git --ssh-private-key-path ~/.ssh/id_ed25519
$ argocd cluster add prod-cluster-context # registers a cluster from your local kubeconfig
$ argocd proj create mission-control \
--dest https://kubernetes.default.svc,'mission-*' \
--src https://github.com/kubestronaut/mission-log-manifests.git
$ argocd app create mission-log \
--repo https://github.com/kubestronaut/mission-log-manifests.git \
--path overlays/prod --revision main \
--dest-server https://kubernetes.default.svc --dest-namespace mission-log \
--sync-policy automated --auto-prune --self-heal
$ argocd app list # every Application, sync status, health status
$ argocd app get mission-log # full detail: source, destination, resource tree
$ argocd app diff mission-log # Git desired state vs. live cluster state, right now
$ argocd app sync mission-log # force a sync outside the reconciliation loop
$ argocd app sync mission-log --prune # include deletions this run, even if prune is off
$ argocd app history mission-log # every synced revision, oldest to newest
$ argocd app rollback mission-log 7 # re-apply a PRIOR revision — see the warning above
$ argocd app wait mission-log --health --timeout 300 # block, e.g. in CI, until Healthy or timeout
$ argocd app set mission-log --sync-policy automated # flip a manually-managed app to automatedOn a throwaway cluster, install Argo CD and create an Application pointing at a public repo path with automated: {} only — no prune, no selfHeal. kubectl scale the Deployment by hand: Argo CD reports OutOfSync and does nothing, the default everyone forgets. Add selfHeal: true and scale again — reverted within seconds. Delete a manifest from your fork and confirm nothing disappears from the cluster until you add prune: true. Three flags, three very different behaviors, one Application. See the stuck-sync drill for a guided version of exactly this exercise.
Gotchas and failure modes
☺ Like you're 10: Most surprises come from a flag you thought was on being off by default, or from Git and the cluster disagreeing about what "current" means.
Non-deterministic charts turn into a permanent, unresolvable diff
A Helm chart using randAlphaNum, now, or lookup renders differently on every single pass. Under a push-based pipeline that's a one-time nuisance; under Argo CD's continuous reconciliation it's permanent, meaningless OutOfSync that never resolves, because the repo server re-renders the chart fresh on every diff and gets a slightly different answer every time. There's no fix inside Argo CD for this — it has to be fixed in the chart, by pinning the value that function was generating instead of generating it at render time.
Cascading prune: deleting a parent can delete everything under it
The resources-finalizer.argocd.argoproj.io finalizer shown in the Application example above is what makes prune: true genuinely dangerous to misjudge on an App-of-Apps root. Delete a root Application carrying that finalizer, and Argo CD doesn't just remove the Application object — it prunes every resource that Application, and transitively every child Application it created, is tracking, cascading all the way down. kubectl delete application root-app --cascade=false deletes only the Application object and leaves everything it created running, which is almost always what you actually want when the thing that's broken is the Application's own config, not the workloads underneath it.
Immutable fields make an app stuck OutOfSync forever
Some Kubernetes fields can't be changed after creation — a Deployment's spec.selector, a StatefulSet's volumeClaimTemplates, a Service's clusterIP. Change one in Git and every sync fails with a "field is immutable" error, leaving the app stuck OutOfSync while the real error hides in the sync result rather than the summary view. The fix is to replace rather than patch — argocd app sync --replace, or the Replace=true sync option — which deletes and recreates the object, so weigh the disruption before reaching for it on anything stateful.
An unscoped project trusts every repo and every destination
The default AppProject that ships out of the box allows any source repository and any destination cluster and namespace — fine for a demo, a real security gap in production, where it means any Application in that project can point at any Git repo and deploy to any namespace Argo CD can reach. An AppProject scopes that down explicitly:
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: mission-control
namespace: argocd
spec:
sourceRepos:
- https://github.com/kubestronaut/mission-log-manifests.git
destinations:
- server: https://kubernetes.default.svc
namespace: 'mission-*'
clusterResourceWhitelist: [] # no cluster-scoped resources — namespaces, CRDs stay off-limits
namespaceResourceBlacklist:
- group: ''
kind: ResourceQuotaEvery Application's spec.project field points at one of these — never leave it defaulted to default on anything that matters, the same discipline Policy-as-Code Philosophy covers for admission policy generally.
Git is the source of truth, and a plaintext Kubernetes Secret checked into Git is a plaintext credential checked into Git — GitOps doesn't change that arithmetic. The standard fixes all keep the encrypted artifact in Git while decrypting only at apply time: Sealed Secrets, the External Secrets Operator (Git holds a reference, not a value, and the real secret is fetched from a vault at sync time), or SOPS with a config-management plugin. This is one of the first decisions to make before an Application's source repo touches anything that isn't already public.
Argo CD vs. the alternatives
☺ Like you're 10: Other tools reconcile the same kind of thing — they just trade a built-in dashboard, a lighter footprint, or skip the reconciler part entirely.
| Option | Model | Best when | Costs you |
|---|---|---|---|
| Argo CD | Pull-based GitOps reconciler; one Application CRD; built-in web UI | Many teams need visibility without cluster access; you want sync waves, hooks and a diff view out of the box | Kubernetes-only; an extra RBAC/SSO surface to run and secure; the UI can become a shadow control plane if people click Sync instead of merging |
| Flux | Pull-based GitOps, composed of narrower controllers — source, kustomize, helm, notification | You want a lighter, more Unix-philosophy toolkit, built-in image automation, or OCI-artifact sources beyond Git | No official UI by default, so developer visibility must be built or bought; more CRDs to learn (GitRepository, HelmRelease, Kustomization) |
Plain CI push (helm upgrade --install from the pipeline) | The pipeline runner applies changes directly, once, at deploy time | A small team, one cluster, and a reconciler's overhead genuinely isn't worth it yet | No drift detection, no self-heal, and cluster credentials live in CI — fails the Pulled Automatically and Continuously Reconciled principles outright |
The honest answer is that Flux and Argo CD are both CNCF-graduated and both fully capable — the choice is about shape and team, not correctness. Choose Argo CD when the platform's customers are many application teams who benefit from seeing their own deployments and a resource tree without asking for a kubeconfig; choose Flux when the platform team is composing its own APIs and values a lighter, more composable controller set over a bundled dashboard. Argo CD does not appear as a domain on the core Kubernetes exams — CKA, CKAD and CKS examine the built-in controllers that use the identical reconcile-loop shape, not this specific implementation of it — but it's explicit, named content on both CGOA's Tooling domain and CAPA's 34% Argo CD domain, and it shows up again, in far more operational depth, on the Platform Engineering course's own Argo CD reference for anyone heading toward CNPE afterward.
Recon the Robot: Sync complete across every child app under the root. Zero drift, all green — except the root Application itself is stuck Progressing.
Foxy: Staging never finished bootstrapping under it, I think. Let me just delete the root app and let the ApplicationSet recreate it clean.
Recon: Careful — that root Application carries the cascade finalizer. Delete it as-is and I prune every resource all of its children created, not just the Application objects.
Gizmo: Or just rip the finalizer out of the manifest first. Then delete's instant and nothing downstream even notices. 🤑
Benny the Beaver: That's the same instinct as ignoring a stuck build step because it's inconvenient. The finalizer's there on purpose — --cascade=false does exactly what Gizmo wants, on the command you actually meant to run.
Timmy the Turtle: And read what --cascade=false leaves orphaned before you run it against prod. I'm not promoting a delete nobody read the flag on.
1. What does "pull-based" mean here, and what's the single biggest security advantage over a push-based pipeline? 2. Distinguish sync status from health status — can an Application be both Synced and Degraded at once? 3. You set syncPolicy.automated but a deleted-from-Git resource is still running in the cluster a week later. What's misconfigured? 4. What's the difference between App-of-Apps and an ApplicationSet — when would you reach for each? 5. You run argocd app rollback mission-log 7 on an Application with selfHeal: true. What happens shortly after, and why? 6. In what sense is Argo CD's own application-controller simultaneously an "in-cluster" and an "external" reconciler?
Check your answers
- "Pull-based" means an agent already running inside the cluster fetches desired state from Git itself, rather than an external pipeline pushing changes in. The biggest security advantage: cluster credentials never have to live in CI, removing a frequently-exploited attack surface — a CI system that also runs untrusted pull-request code — entirely.
- Sync status (
Synced/OutOfSync/Unknown) asks whether live state matches Git. Health status (Healthy/Progressing/Degraded/Suspended/Missing/Unknown) asks whether what's running is actually working. Yes — a Deployment can be exactly what Git specifies (Synced) while its pods crash-loop on a bad readiness probe (Degraded). pruneis a separate boolean fromautomatedand defaults tofalseeven whenautomatedis set. Withoutprune: true, Argo CD applies new and changed resources automatically but never deletes resources removed from Git.- App-of-Apps is for managing many different apps as one bootstrap unit — a root Application whose source is a directory of other Application manifests. An ApplicationSet is for the same app templated across many targets — one generator (List, Cluster, Git, Matrix, Pull Request) producing near-identical Applications from one template, avoiding hand-maintained copies that drift.
- Nothing changes on the Git side — no commit, no moved HEAD. With
selfHeal: true, the next reconciliation cycle sees live state diverging from Git's still-current HEAD (the commit the rollback was trying to escape) and re-applies it, undoing the rollback within minutes. The GitOps-correct rollback isgit reverton the manifest repo. - Its own controller pod always runs inside exactly one hub cluster, making it in-cluster relative to that cluster. But via
argocd cluster addit can also register and reconcile many other destination clusters from that single hub, holding credentials for every one of them — which is exactly the external-reconciler shape from every other cluster's point of view.