GitOps with ArgoCD
Lesson 14 · Cloud Infrastructure & DevOps · ~15 min read
1. The deploy step that was always wrong
Through L11 the deploy step was “run helm upgrade against the cluster”. That works on the day a developer is online, has cluster admin, and remembers the right values file. It fails on every other day: nobody knows what the cluster looks like without logging in and checking; rollback requires a developer who knows the previous values; “what version is in prod?” needs a human to answer. These are solvable problems, but they require institutional memory and human time rather than automation.
GitOps replaces the deploy step entirely. The desired state lives in git; a controller in the cluster watches git and makes the cluster match. Deploys become git push. Rollbacks become git revert. “What’s in prod?” becomes “what’s at HEAD of main?”. Audit trail is git log. This is not a tooling change — it is a sourcing-of-truth change. Cluster state is no longer authoritative; git is. When they diverge (drift), the controller fixes it, not a human.
2. The two camps: ArgoCD and Flux
Two open-source GitOps controllers dominate in 2026:
- ArgoCD — Argo Project (CNCF). Strong UI, explicit
ApplicationCR, easy to learn, mature App-of-Apps pattern. - Flux — CNCF. CLI-first, more composable, native Helm/Kustomize controllers baked in as separate CRDs (
HelmRelease,Kustomization).
Both do the same job. Pick ArgoCD if your team likes a UI for “what is in the cluster vs what is in git”. Pick Flux if your team is CLI-first and comfortable with a steeper initial learning curve in exchange for more composable primitives. The patterns in this lesson — Application = source + dest + syncPolicy; sync waves; drift detection — port between them conceptually even if the CRD names differ.
This lesson uses ArgoCD because the UI is the best teaching aid for “see the diff between git and cluster” — the central concept of GitOps. Once you understand the concept in ArgoCD’s visual form, reading a Flux HelmRelease CR is straightforward.
3. ArgoCD architecture
Three core components run in the argocd namespace:
- argocd-server — the REST API plus the web UI. This is where humans log in to see what the cluster looks like and where the
argocdCLI connects. - argocd-repo-server — clones git repos and runs
helm templateorkustomize buildto render manifests into plain Kubernetes YAML. Stateless; can be scaled horizontally. - argocd-application-controller — the reconciliation loop. Every 3 minutes by default, it diffs desired state (rendered by repo-server) vs live state (from the Kubernetes API) and applies, prunes, or reports drift according to the
syncPolicy.
Optional components: argocd-dex (SSO/OIDC), argocd-redis (manifest cache), argocd-notifications-controller (Slack/email on sync events). A minimal install omits these and uses the local admin password instead of OIDC — fine for learning, not fine for production.
The reconciliation path: the application-controller asks the repo-server to render Application X. The repo-server clones the target revision from git, runs Helm, returns plain manifests. The application-controller compares those manifests to the live objects in the cluster. Differences become either Sync actions (if automated is configured) or OutOfSync status reports (if manual sync is required).
4. The Application CR
The Application CR is the core contract between git and the cluster:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: url-shortener
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/me/url-shortener
path: cloud-devops/sample-app/helm
targetRevision: main
helm:
valueFiles: [values-prod.yaml]
destination:
server: https://kubernetes.default.svc
namespace: url-shortener
syncPolicy:
automated:
prune: true # delete K8s resources that disappear from git
selfHeal: true # revert manual edits to live resources
allowEmpty: false
syncOptions:
- CreateNamespace=true
- ApplyOutOfSyncOnly=true
ignoreDifferences:
- group: apps
kind: Deployment
jsonPointers:
- /spec/replicas # HPA owns this — don't reconcile
source— where the manifests are. Supports Helm, Kustomize, plain YAML, or a directory of mixed files.destination— which cluster and namespace.kubernetes.default.svcmeans the cluster ArgoCD itself runs in. External clusters are registered by URL and credentials.syncPolicy.automated— without it, every sync is a manual click orargocd app synccommand. With it, the application-controller reconciles on schedule and on git push detection.prune: true— if a resource disappears from git, delete it from the cluster. Without this, removed manifests leave orphan objects in the cluster indefinitely.selfHeal: true— if someone edits a live resource manually, revert it to the git version on the next reconciliation. This is the “drift correction” half of GitOps.ignoreDifferences— the HPA escape hatch. Without this entry, every HPA scale-up is followed by an ArgoCD revert ofspec.replicasto the value in git.
5. App-of-Apps and ApplicationSet
Two patterns for scaling the Application primitive to cover a full cluster.
App-of-Apps: one root Application whose source path is a directory of child Application manifests. Apply the root once; ArgoCD discovers and reconciles each child. Cluster bootstrap becomes a single command:
# root Application
spec:
source:
repoURL: https://github.com/me/cluster
path: argocd/children # directory full of Application YAMLs
targetRevision: main
directory:
recurse: true
Adding a new service to the cluster means adding a new YAML file in argocd/children/ and pushing. ArgoCD picks it up on the next poll. The pattern handles heterogeneous sets of resources: one Application for cert-manager, one for ingress-nginx, one for the monitoring stack, one for the app itself, each with its own source repo and sync policy.
ApplicationSet: one template plus one generator renders many Applications. Best for homogeneous fan-out — the same Helm chart deployed across N environments or clusters. The generators include:
list— explicit list of environments or clusters.cluster— one Application per registered cluster in ArgoCD.git— one Application per subdirectory of a git tree (used for monorepos).matrix— combine two generators, e.g., environments × clusters.
spec:
generators:
- list:
elements:
- { env: dev, valueFile: values-dev.yaml }
- { env: stage, valueFile: values-stage.yaml }
- { env: prod, valueFile: values-prod.yaml }
template:
metadata: { name: 'url-shortener-{{env}}' }
spec:
source:
helm:
valueFiles: ['{{valueFile}}']
Three Applications from one definition. Adding a qa environment is one line. The ApplicationSet controller manages the lifecycle of all rendered Applications — including deletion when an element is removed from the generator list.
6. Sync waves
Order matters during a cluster bootstrap. cert-manager needs to install its CRDs before ingress-nginx tries to create Certificate resources. Without ordering, ArgoCD applies everything in parallel and the first sync often fails with “no kind Certificate” errors.
Sync waves are a per-manifest annotation:
metadata:
annotations:
argocd.argoproj.io/sync-wave: "-2"
Lower numbers apply first. The standard layout for this course’s stack:
- Wave −2: namespaces, CRDs (if installing standalone CRD bundles).
- Wave −1: controllers that other resources depend on — cert-manager, ingress-nginx, prometheus-operator.
- Wave 0: the app itself (default wave; no annotation needed).
- Wave 1: post-deploy jobs (DB migrations, smoke-test Jobs).
Within a wave, manifests apply in parallel. Across waves, ArgoCD waits for the previous wave’s resources to reach a Healthy state before starting the next wave. This is the entire cluster-bootstrap order expressed as one annotation per manifest file — no separate dependency graph tool required.
7. Drift detection and the selfHeal deal
automated.selfHeal: true is a bargain: nobody is allowed to kubectl edit resources that ArgoCD manages, because edits get reverted within 3 minutes. The trade: you lose the ability to “quickly poke a value in prod without a PR”; you gain a cluster where the live state genuinely matches git at all times. In practice, the second is far more valuable than the first once you have been woken up by a production incident caused by an untracked manual edit.
The HPA exception is the only common case where you want a live edit to persist. List replicas in ignoreDifferences and ArgoCD stays out of the autoscaler’s lane. Other common ignoreDifferences entries:
HorizontalPodAutoscaler .status— always changing; not actionable from git.Service .spec.clusterIP— assigned by Kubernetes at creation, not stored in git, always “different” unless excluded.Deployment metadata.annotations— if an admission webhook injects annotations (e.g., Vault agent injector), these won’t be in git and will appear as drift on every sync.
A clean ignoreDifferences list keeps the OutOfSync count meaningful. If every Application is always OutOfSync due to spurious diffs, nobody looks at the warnings when a real drift happens. Noise eliminates signal.
Common pitfalls
automated: { selfHeal: true }withoutignoreDifferencesfor HPA → constant HPA flap: ArgoCD reverts the replicas that HPA just set, HPA scales again, ArgoCD reverts again, indefinitely.- App-of-Apps where the root Application is also managed by the root Application → the root Application YAML is in the
children/directory it watches. ArgoCD doesn’t crash, but the controller burns CPU on recursive reconciliation. Keep the root outside the children directory. targetRevision: HEADinstead of a branch name → ArgoCD follows the default branch, but on detached HEADs (e.g., after agit checkout <sha>) you may see unrelated changes deploy silently. Usemainor a tag.- Forgetting
CreateNamespace=trueinsyncOptions→ first deploy fails because the destination namespace doesn’t exist. ArgoCD does not create namespaces by default. - Storing secrets in
values-prod.yamlin git → the values file is public (or accessible to everyone with repo access) and is now in the repo history forever. Use Sealed Secrets or external-secrets-operator; both are covered in the L14 homework.