Lesson 14 GitOps with ArgoCD
Theme:

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:

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:

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

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:

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:

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:

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

  1. automated: { selfHeal: true } without ignoreDifferences for HPA → constant HPA flap: ArgoCD reverts the replicas that HPA just set, HPA scales again, ArgoCD reverts again, indefinitely.
  2. 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.
  3. targetRevision: HEAD instead of a branch name → ArgoCD follows the default branch, but on detached HEADs (e.g., after a git checkout <sha>) you may see unrelated changes deploy silently. Use main or a tag.
  4. Forgetting CreateNamespace=true in syncOptions → first deploy fails because the destination namespace doesn’t exist. ArgoCD does not create namespaces by default.
  5. Storing secrets in values-prod.yaml in 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.

References