DevOps GitOps

GitOps with Argo CD and Flux: Deliver from Git

Quick take: GitOps makes Git the single source of truth for both application and infrastructure state. A controller running inside the cluster continuously watches a repo and reconciles the live cluster toward the committed manifests. If someone kubectl edits a resource by hand, the controller detects the drift and (when self-heal is on) reverts it. Deployment becomes a git push; rollback becomes a git revert; the audit log is your commit history.

A platform team of six ran eleven Kubernetes clusters across three regions and two cloud accounts. Manifests lived in a wiki, in kubectl apply muscle memory, and in three people’s heads. Two clusters had quietly diverged — different ingress-nginx versions, a replicas: 1 someone set during an incident and never reverted, a ConfigMap patched live and lost on the next deploy. Nobody could answer “what is actually running in prod-eu?” without reading the cluster. A bad Friday deploy took ninety minutes to roll back because the rollback was re-running the pipeline with last week’s image tag, and the build cache had expired.

They adopted GitOps. Every manifest moved into Git. Argo CD (and later Flux on the edge clusters) ran inside each cluster, watched a repository, and made the live state match the committed state. The cluster became a follower of Git. Drift — including that stray replicas: 1 — was reverted within minutes, automatically. Rollback became git revert <sha> && git push, reconciled in under a minute. “What’s running in prod-eu?” became “read clusters/prod-eu/ on main.” This is the model this article teaches end to end, at the depth a senior platform engineer needs to run it in production — not the marketing version.

You will learn the four GitOps principles and why each one matters operationally, how the reconciliation loop works (observe → diff → act, every few minutes and on every webhook), how drift detection and self-heal differ and when to enable each, how Argo CD and Flux differ and where each wins, the app-of-apps and ApplicationSet patterns for managing dozens of apps and clusters, sync waves and hooks for ordering and migrations, multi-cluster topologies, the three real answers to secrets in Git (SOPS, Sealed Secrets, External Secrets Operator), and how GitOps composes with progressive delivery (Argo Rollouts / Flagger). Everything is grounded in real CRDs, real CLI, and real failure modes — with the tables you keep open while you operate.

What problem this solves

Traditional CI/CD is push-based imperative delivery: a pipeline runner outside the cluster holds cluster credentials, runs kubectl apply or helm upgrade, and hopes the result matches intent. Three structural problems fall out of that model, and GitOps is the direct answer to all three.

First, there is no continuously-enforced source of truth. The pipeline applies a manifest once, at deploy time. Five minutes later someone runs kubectl scale during an incident, an operator mutates a resource, or a helm rollback half-completes — and the live cluster no longer matches anything in version control. The divergence is invisible until it bites. GitOps closes the loop: a controller re-asserts the committed state on a schedule, so the cluster is always converging back to Git, not just at deploy time.

Second, credentials point the wrong way. Push CD requires your CI runner (often a SaaS outside your network) to hold cluster admin credentials and reach the API server — frequently meaning a publicly-exposed control plane or a long-lived kubeconfig in a secrets store. GitOps inverts this to pull-based: the agent runs inside the cluster and reaches out to Git and the registry. No external system needs cluster credentials; the API server can be fully private. This is the single biggest security argument for GitOps.

Third, rollback and audit are afterthoughts. “Roll back” in push CD means re-running an old pipeline, which may no longer be reproducible (expired caches, mutable :latest tags, drifted dependencies). “Who changed this and why?” means correlating pipeline logs, tickets, and Slack. In GitOps, the desired state is the Git history: rollback is git revert, the audit trail is git log and PR reviews, and every production change went through a reviewable, signed, attributable commit.

Who hits these problems: any team running Kubernetes at more than toy scale — multiple environments, multiple clusters, more than a couple of engineers with kubectl access, or a compliance requirement that every production change be reviewed and attributable. It bites hardest on multi-cluster fleets (drift multiplies per cluster), regulated workloads (you must prove what ran and who approved it), and platform teams offering self-service (you need a guardrail that isn’t “trust everyone with cluster-admin”).

To frame the whole field before the deep dive, here is the contrast that drives every later decision:

Dimension Push-based imperative CD Pull-based GitOps
Source of truth The pipeline run (ephemeral) Git repo (durable, versioned)
Who applies changes CI runner outside the cluster Controller inside the cluster
Credential direction CI → cluster (outbound to API server) Cluster → Git/registry (inbound only)
Enforcement Once, at deploy time Continuously (loop + webhook)
Drift handling Invisible until next deploy Detected; optionally auto-reverted
Rollback Re-run an old pipeline (may not reproduce) git revert + reconcile
Audit trail Pipeline logs + tickets, scattered git log, PRs, signed commits
API-server exposure Often public / broad RBAC for CI Can be fully private
Multi-cluster N pipelines, N credential sets One repo pattern, agent per cluster

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable with Kubernetes fundamentals: Deployments, Services, ConfigMaps, Namespaces, RBAC (ServiceAccounts, Roles, ClusterRoles), and reading/writing YAML manifests. You should know how kubectl apply works (server-side vs client-side apply, the last-applied-configuration annotation) and what a CRD and a controller/operator are — GitOps tools are controllers reconciling CRDs. Hands-on experience with Kustomize (bases and overlays) and Helm (charts, values, releases) is assumed; GitOps tools render manifests through exactly those two engines. You should be able to run kubectl, helm, and git from a shell.

This article sits in the delivery & platform-engineering track. It is the deployment-mechanism layer underneath your release strategy: it assumes you understand CI/CD Pipelines Explained: From Code Commit to Production (GitOps replaces the CD half, not the CI half — you still build and test images in a pipeline), and it pairs tightly with Deployment Strategies: Blue-Green, Canary and Rolling Updates and Progressive Delivery and Feature Flags: Release Without Fear, because GitOps is how you store and enforce those strategies declaratively. It leans on Git Branching Strategies: Trunk-Based, GitFlow and Feature Branches (your repo layout is a branching decision) and on Infrastructure as Code: Terraform, Pulumi, CDK and Cloud-Native Options (GitOps is “IaC for the cluster’s runtime state”). Secrets connect to CI/CD Secrets and Credential Management: Secure Your Pipelines, and the operational signals connect to DevOps Observability: Logs, Metrics, Traces and SLOs.

A quick map of who owns what during a GitOps incident, so you call the right layer fast:

Layer What lives here Who usually owns it Failure classes it can cause
Git repo (desired state) Manifests, Kustomize/Helm sources App + platform teams ComparisonError (bad YAML), wrong path/revision
CI (build & push) Image build, tests, image tag write-back App / dev team Stale tag, image not in registry → ImagePullBackOff
GitOps controller Argo CD / Flux, reconcile loop, RBAC Platform team Stuck Progressing, won’t self-heal, hook deadlock
Rendering engine Kustomize / Helm template Platform team Render diff, Helm hook conflicts, CRD ordering
Cluster runtime API server, controllers, admission webhooks Platform / SRE Drift fights (HPA/mutating webhooks), prune disasters
Secrets backend SOPS keys / KMS / Vault / ESO Security + platform Secret empty → crash loop, decryption failure

Core concepts

Five mental models make every later section obvious. Read them once; the rest of the article is consequences.

Git is the desired state; the cluster is the actual state; reconciliation is the function that minimises their difference. This is the entire idea expressed as control theory. You declare what you want in Git (the setpoint). A controller continuously measures what is (the cluster’s live objects) and applies the delta to drive actual toward desired. Everything else — drift, self-heal, sync waves, multi-cluster — is a refinement of this one loop. Crucially, the controller never “does a deploy” in the imperative sense; it converges. A deploy is just “the setpoint moved, so the next reconcile moves the cluster.”

The controller runs inside the cluster and pulls. Argo CD’s application-controller and Flux’s source-controller/kustomize-controller/helm-controller are Pods in the cluster. They reach out to Git (clone/fetch) and the registry, render manifests, and apply them to the local (or a registered remote) API server. No external CI system needs cluster credentials. This pull model is why the API server can be private and why a compromised CI runner can’t directly touch prod — it can only open a pull request.

Drift is any difference between live and desired; how you respond to it is a policy, not a fact. Detection is always on (the diff is computed every reconcile). The response is configurable: report only (show OutOfSync, alert, let a human act), self-heal (revert the live object back to Git automatically), and prune (delete live objects that no longer exist in Git). These are independent knobs. Aggressive self-heal on the wrong resource starts a fight with another controller (an HPA managing replicas, a mutating webhook injecting a sidecar) — so you must know which fields you own and which the platform owns.

Rendering is Kustomize or Helm; GitOps tools don’t invent a new templating language. Argo CD and Flux both call kustomize build or helm template (Flux’s Helm controller does a real Helm release; Argo CD by default renders templates and applies them, with a flag to use real Helm). So “GitOps YAML” is just your normal Kustomize overlays and Helm charts, plus a thin Application (Argo CD) or Kustomization/HelmRelease (Flux) CRD that says which repo, which path, which revision, which cluster/namespace, and which sync policy. Master Kustomize and Helm first; the GitOps layer is a wrapper.

Sync is not atomic, and ordering is explicit. A sync applies many objects. By default they go in a sensible order (namespaces and CRDs first), but cross-resource dependencies (run the DB migration before the new app version; create the namespace before the resources in it) are your responsibility, expressed with sync waves (Argo CD annotations) or dependsOn (Flux). A hook is a special object that runs at a phase boundary (before sync, after sync, on failure) — typically a Job for migrations, smoke tests, or notifications. Get ordering wrong and you get a half-applied state that looks like a flaky deploy but is a missing wave annotation.

The vocabulary in one table

Pin down every moving part before the deep sections. The glossary repeats these for lookup; this is the mental model side by side:

Term One-line definition Argo CD form Flux form
Desired state What you want, in Git The repo path the Application points at The path a Kustomization builds
Actual state What’s live in the cluster Live objects the controller tracks Live objects the controller tracks
Reconciliation Drive actual → desired application-controller loop kustomize/helm-controller loop
App / unit of delivery A bundle of resources from one source Application CRD Kustomization / HelmRelease CRD
Source Where manifests come from spec.source (repo/path/revision) GitRepository / OCIRepository / HelmRepository
Sync / apply Make the cluster match “Sync” (manual or auto) “reconcile” (interval or on-demand)
Drift Live ≠ desired OutOfSync status drift in kustomize-controller
Self-heal Auto-revert drift syncPolicy.automated.selfHeal spec.force / continuous apply
Prune Delete orphaned objects syncPolicy.automated.prune spec.prune: true
Ordering Apply in stages sync waves (annotation) dependsOn + health gates
Lifecycle hook Run a Job at a phase resource hooks (PreSync/PostSync) Helm hooks / Kustomization deps
Fan-out generator Make N apps from a template ApplicationSet Kustomization per cluster + substitution

The four GitOps principles

The CNCF OpenGitOps working group distilled GitOps into four principles. They’re not slogans — each one has a concrete operational meaning and a failure mode if you violate it.

# Principle What it means concretely What breaks if you violate it
1 Declarative The entire system’s desired state is expressed as data (YAML), not scripts/commands Imperative kubectl steps can’t be diffed, reverted, or reconciled — drift is unmanageable
2 Versioned & immutable Desired state is stored in Git: versioned, append-only history, immutable revisions No audit trail, no atomic rollback, “what changed?” is unanswerable
3 Pulled automatically Software agents pull the declared state from the source automatically Push CD needs cluster creds outside the cluster; humans forget to re-apply
4 Continuously reconciled Agents continuously observe and reconcile actual → desired Drift accumulates silently; the cluster diverges from intent between deploys

The deep implication of principle 4 is the one people miss: GitOps is not “deploy on merge.” “Deploy on merge” is just push CD triggered by Git. True GitOps continuously reconciles whether or not anything merged — so a manual kubectl edit made at 3 a.m. is reverted at the next reconcile even though no commit happened. If your “GitOps” only acts on a webhook and never re-checks the cluster, you have automated push CD, not GitOps, and you don’t get drift correction.

A second subtlety on principle 2: immutable means you reference a specific revision (a commit SHA or a semver tag), not a moving branch for the artifact you actually run in prod. Pointing prod at HEAD of main is convenient but means “what’s deployed” changes the instant someone merges — you lose the ability to say “prod is at exactly this SHA” and to promote a specific tested revision. Mature setups pin environments to tags or SHAs and promote by changing the pin in a PR.

How the reconciliation loop actually works

The reconciliation loop is the heart of GitOps. Both tools implement the same conceptual cycle — observe, diff, act — differing in component layout and defaults.

The loop, step by step, for a single application:

Phase What the controller does Argo CD component Flux component
Observe (source) Fetch the latest manifests from Git/OCI/Helm repo-server clones & renders source-controller fetches GitRepository
Observe (live) List live objects in the target cluster application-controller (informer cache) kustomize-controller (cluster reader)
Render Run Kustomize/Helm to produce the desired manifests repo-server (kustomize build / helm template) kustomize-controller (kustomize build)
Diff Compute the delta (desired − actual), field by field application-controller kustomize-controller
Decide Is it OutOfSync? Should we sync (auto/manual)? sync policy reconcile on interval/event
Act (apply) Server-side apply the delta; create/update/delete application-controller kustomize-controller (SSA)
Assess health Evaluate resource health (Deployments ready, etc.) health checks (Lua) health checks / wait
Report Update status, emit events/metrics, notify status + notifications Kustomization status + events

What triggers a reconcile

A reconcile happens on three triggers, and understanding them prevents the “why didn’t my change deploy?” confusion:

Trigger When it fires Default cadence How to tune
Polling interval Periodically, regardless of events Argo CD: ~3 min repo poll; Flux: spec.interval (e.g. 1m/10m) Argo CD timeout.reconciliation; Flux per-resource interval
Git webhook On push to the watched repo Immediate (if configured) Argo CD webhook endpoint; Flux Receiver
Manual / API A human or pipeline forces it On demand argocd app sync; flux reconcile kustomization

The polling interval is the floor on how fast drift is corrected when self-heal is on, and the floor on deploy latency if you don’t wire webhooks. Setting Flux interval: 1m on every Kustomization across a large fleet hammers the API server and Git; setting it to 10m means drift can persist for ten minutes. The pattern is short interval + webhooks: the webhook handles “deploy fast on merge,” the interval handles “correct drift and catch missed webhooks.” Argo CD additionally watches live objects via Kubernetes informers, so it reacts to drift faster than its poll interval suggests — a kubectl edit is often reverted within seconds, not at the next 3-minute poll.

Server-side apply and three-way diff

The act phase is not a naive overwrite. Both tools use server-side apply (SSA) with field management, computing a three-way diff between (a) the desired manifest, (b) the live object, and © the last-applied state. This is why GitOps doesn’t clobber fields it doesn’t manage — an HPA-managed replicas or a webhook-injected sidecar container is owned by a different field manager and is left alone, unless you also declare that field, which is exactly how drift fights start.

Diff/apply concept What it does Why it matters for GitOps
Three-way diff Compares desired vs live vs last-applied Detects drift precisely, ignores fields you don’t set
Server-side apply (SSA) API server merges based on field ownership Multiple controllers can co-own one object safely
Field manager The named owner of a set of fields Argo CD/Flux own only fields they declare
ignoreDifferences Tell the diff to skip certain fields/paths Stop fighting HPA replicas, webhook mutations, CA bundles
Replace vs apply Recreate the object vs merge-patch Needed for immutable fields (some CRDs, Job spec)

A field-ownership example you’ll hit within a week: you set replicas: 3 in Git, but an HPA also manages replicas. With self-heal on, the controller reverts to 3 every reconcile, the HPA scales back up, and the two ping-pong. The fix is to not declare replicas (let the HPA own it) and add ignoreDifferences for /spec/replicas. Knowing which fields you own is the difference between a stable GitOps setup and a flapping one.

Drift detection, self-heal, and prune

These three are independent policies, and conflating them causes most GitOps accidents. Detection is free and always on; the response is what you configure.

Policy What it does Default (Argo CD) Default (Flux) Main risk if mis-set
Drift detection Compute and report live vs desired diff (OutOfSync) Always on Always on None (read-only)
Self-heal Auto-revert live drift back to Git Off (manual sync) Continuous apply (effectively on) Fights other controllers (HPA, webhooks)
Auto-sync Apply new Git revisions automatically Off On (per interval) Bad merge ships instantly without a gate
Prune Delete live objects removed from Git Off Off (prune: false) A bad render can delete prod objects
PruneLast Prune only after everything else synced healthy Off n/a Safer prune ordering
Selective sync Sync only chosen resources Manual UI/CLI n/a

Detection: what counts as drift

Drift is computed by the three-way diff, so it only flags fields the controller manages. The drift you should heal is a manual kubectl edit/scale or a half-applied previous sync — re-assert Git (and capture the lesson in a commit). The drift you should not heal is another controller doing its job: an HPA owning replicas, a mutating webhook injecting a sidecar, cert-manager or a service mesh patching annotations — for those, ignoreDifferences or exclude the resource. And an out-of-band hotfix that should be permanent isn’t really drift: promote it to Git first, and it becomes desired state. The operational rule: self-heal the fields you own; ignore the fields other controllers own. Most “GitOps keeps reverting my change” complaints are someone fighting an HPA or a mutating admission webhook over a field they shouldn’t have declared.

Self-heal and prune in real YAML (Argo CD)

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: shop-api
  namespace: argocd
spec:
  project: shop
  source:
    repoURL: https://github.com/acme/shop-deploy.git
    targetRevision: v2.4.1          # pin to a tag, not HEAD, for prod
    path: apps/shop-api/overlays/prod
  destination:
    server: https://kubernetes.default.svc
    namespace: shop
  syncPolicy:
    automated:
      prune: true                    # delete objects removed from Git
      selfHeal: true                 # revert manual drift back to Git
    syncOptions:
      - CreateNamespace=true         # create the target namespace if missing
      - PruneLast=true               # prune only after healthy sync (safer)
      - ApplyOutOfSyncOnly=true      # only apply resources that actually differ
    retry:
      limit: 5
      backoff:
        duration: 10s
        factor: 2
        maxDuration: 5m
  ignoreDifferences:
    - group: apps
      kind: Deployment
      jsonPointers:
        - /spec/replicas            # HPA owns this — don't fight it

The equivalent in Flux (Kustomization)

apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: shop-api
  namespace: flux-system
spec:
  interval: 5m                       # reconcile cadence (drift floor)
  retryInterval: 1m
  sourceRef:
    kind: GitRepository
    name: shop-deploy
  path: ./apps/shop-api/overlays/prod
  prune: true                        # delete orphaned objects
  wait: true                         # block until resources are healthy
  timeout: 5m
  force: false                       # true => replace immutable resources
  patches:
    - patch: |
        - op: remove
          path: /spec/replicas       # let HPA own replicas
      target:
        kind: Deployment
        name: shop-api

A hard-won safety note on prune: enabling prune: true plus a bad render (a Kustomize error that produces an empty manifest set, or a path typo that points at an empty directory) can cause the controller to conclude “all these objects were removed from Git” and delete your entire app. Guardrails: keep prune off until you trust the pipeline, use PruneLast, set prune: false on the bootstrap/root app, and (Argo CD) use Validate=true and resource-exclusion. Treat prune like rm -rf with a loaded source.

Argo CD vs Flux: how they differ and how to choose

Both are CNCF-graduated GitOps controllers. They solve the same problem with different philosophies: Argo CD is a single, batteries-included application delivery platform with a strong UI and an application-centric model; Flux is a set of composable single-responsibility controllers (the GitOps Toolkit) with a CLI-and-CRD-first model and tight OCI/Helm integration. Neither is “better” — they fit different teams.

Dimension Argo CD Flux (GitOps Toolkit)
Architecture Monolithic-ish: api-server, repo-server, application-controller, redis, dex Microservices: source-, kustomize-, helm-, notification-, image-controllers
UI First-class web UI (app topology, diff, sync, rollback) No built-in UI (use flux CLI; Weave GitOps / third-party UIs exist)
Primary CRD Application, ApplicationSet, AppProject GitRepository, Kustomization, HelmRelease, OCIRepository
Multi-tenancy AppProject (allowed repos/clusters/namespaces) Namespaced reconcilers + RBAC + tenant model
Helm strategy Renders helm template by default (no Tiller); optional native Real Helm releases via helm-controller (proper hooks/rollback)
Multi-cluster Hub model: one Argo CD manages many registered clusters Agent-per-cluster (each cluster runs its own Flux) is idiomatic
Image automation Argo CD Image Updater (separate component) Built-in image-reflector + image-automation controllers
Drift/self-heal Explicit selfHeal flag; informer-driven, fast Continuous apply on interval
Progressive delivery Argo Rollouts (sibling project) Flagger (sibling project)
Notifications Argo CD Notifications notification-controller (native)
Secrets Bring-your-own (SOPS/Sealed/ESO via plugins) Native SOPS decryption in kustomize-controller
Operating style Click-or-CLI; great for app teams & visibility CLI/CRD/GitOps-native; great for platform automation

Where each one wins

If you need… Lean toward Why
A visual app topology + diff/sync for app teams Argo CD The UI is best-in-class for self-service and incident triage
One control plane managing many clusters Argo CD Hub model + ApplicationSet cluster generators
Native, first-class Helm releases (real hooks/rollback) Flux helm-controller runs actual Helm, not just template
Native SOPS decryption with no extra plugins Flux Built into kustomize-controller
Built-in image-tag automation in-cluster Flux image-reflector + image-automation controllers
Composable, single-responsibility controllers Flux The Toolkit is designed to be assembled
A turnkey, all-in-one delivery platform Argo CD Fewer moving parts to wire together
Strict multi-tenant repo/cluster guardrails with a UI Argo CD AppProject + RBAC + SSO out of the box

Many mature platforms run both: Argo CD for the application teams who want the UI and self-service, Flux for the platform layer (cluster add-ons, image automation) where everything should be CRD-driven and headless. They coexist fine as long as they don’t both manage the same objects.

Component reference

The controllers you’ll actually watch, restart, and read logs from:

Tool Component Responsibility Where it can break
Argo CD argocd-application-controller Diff, sync, health, self-heal Stuck syncs, OOM on huge apps, slow reconcile
Argo CD argocd-repo-server Clone repos, render Kustomize/Helm ComparisonError, render OOM, slow Git
Argo CD argocd-server (API/UI) API, web UI, RBAC, SSO Auth/SSO issues, UI down
Argo CD argocd-redis Cache for repo-server/controller Cache loss → slow/incorrect diffs
Argo CD argocd-applicationset-controller Generate Applications from generators Bad generator → app sprawl/deletion
Flux source-controller Fetch Git/OCI/Helm artifacts Auth to repo/registry, large repos
Flux kustomize-controller Build Kustomize, SSA, prune, SOPS decrypt Render errors, prune disasters, decrypt fail
Flux helm-controller Reconcile HelmRelease (real Helm) Helm upgrade/rollback failures, hook deadlocks
Flux notification-controller Alerts + Receiver webhooks Missed alerts; webhook receiver down
Flux image-reflector / image-automation Scan tags; write new tags to Git Tag policy mistakes; unwanted commits

App-of-apps and ApplicationSets: managing the fleet

One Application per microservice per environment per cluster doesn’t scale by hand — fifty services across four clusters is two hundred objects to create and keep consistent. Two patterns solve this: app-of-apps (a root app that points at a directory of child app definitions) and ApplicationSets (a controller that generates Applications from a template + a generator).

App-of-apps

The app-of-apps pattern bootstraps and manages many apps from one root. You create a single Argo CD Application whose source is a directory of more Application manifests. Argo CD syncs the root, which creates the children, which sync their real workloads. It’s GitOps managing GitOps.

# root-app.yaml — the single bootstrap app you create by hand (or via Terraform)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root
  namespace: argocd
spec:
  project: platform
  source:
    repoURL: https://github.com/acme/gitops.git
    targetRevision: main
    path: bootstrap/apps          # a folder full of Application manifests
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated: { prune: true, selfHeal: true }
gitops/
├── bootstrap/
│   └── apps/                     # the "app-of-apps" — one Application per child
│       ├── shop-api.yaml         # → apps/shop-api/overlays/prod
│       ├── shop-web.yaml
│       ├── ingress-nginx.yaml    # platform add-on
│       └── cert-manager.yaml
└── apps/
    ├── shop-api/{base,overlays/{dev,prod}}
    └── shop-web/{base,overlays/{dev,prod}}

The trade-off: app-of-apps is simple and explicit (every child is a file you can read), but it’s static — adding a cluster means hand-editing child files, and there’s no templating across them. That’s where ApplicationSets come in.

ApplicationSets and generators

The ApplicationSet controller renders many Applications from one template, driven by a generator that produces a list of parameter sets. This is the scalable answer to “the same app on every cluster” or “an app per directory in the repo.”

Generator Produces one Application per… Classic use
List Hard-coded list element A fixed, small set of clusters/regions
Cluster Registered Argo CD cluster (by label) Deploy an add-on to every prod cluster
Git (directories) Directory matching a path glob One app per service folder in a monorepo
Git (files) A config file matching a glob Per-app config files drive per-app deploys
Matrix Cartesian product of two generators (every cluster) × (every app)
Merge Merge of generators on a key Override per-cluster values for a base set
Pull Request Open PR in a repo Ephemeral preview environments per PR
SCM Provider Repo in an org/group Onboard every repo in a GitHub org

A real Cluster generator that deploys ingress-nginx to every cluster labelled env=prod:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: ingress-nginx
  namespace: argocd
spec:
  goTemplate: true
  generators:
    - clusters:
        selector:
          matchLabels:
            env: prod              # only clusters labelled env=prod
  template:
    metadata:
      name: 'ingress-nginx-{{.name}}'   # one app per cluster
    spec:
      project: platform
      source:
        repoURL: https://github.com/acme/gitops.git
        targetRevision: main
        path: addons/ingress-nginx
      destination:
        server: '{{.server}}'      # the cluster's API server URL
        namespace: ingress-nginx
      syncPolicy:
        automated: { prune: true, selfHeal: true }
        syncOptions: [CreateNamespace=true]

The power and the danger are the same property: one change to the generator or template fans out to every generated app. A typo in the template can create — or, with prune on, delete — dozens of apps across clusters at once. Treat ApplicationSet changes with the review rigor of an infrastructure change, and consider applicationsSync: create-only or preserveResourcesOnDeletion while you build confidence.

Pattern Best for Strength Watch-out
Single Application A handful of apps, one cluster Dead simple, explicit Doesn’t scale; copy-paste drift
App-of-apps Bootstrapping a cluster’s full app set One root to sync everything Static; per-cluster edits are manual
ApplicationSet Same app across many clusters/dirs DRY, scales to fleets Fan-out blast radius; harder to read
ApplicationSet + Matrix (clusters) × (apps) at scale Maximum DRY Cartesian sprawl; debugging is harder

Sync waves and hooks: ordering and lifecycle

A sync applies many objects, but real apps have ordering constraints: the namespace must exist before its resources; a CRD must be installed before a custom resource that uses it; a database migration must run before the new app version starts; a smoke test should run after. GitOps gives you two ordering mechanisms — sync waves (intra-sync ordering of resources) and resource hooks (run a Job at a phase boundary).

Sync waves (Argo CD)

A sync wave is an integer annotation on a resource. Argo CD applies resources in ascending wave order, waiting for each wave’s resources to be healthy before starting the next. Default wave is 0; CRDs/namespaces get applied early automatically, but anything else you order yourself.

# Wave -1: install the CRD first
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: widgets.acme.io
  annotations:
    argocd.argoproj.io/sync-wave: "-1"
---
# Wave 0 (default): the app's config and deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-api
  annotations:
    argocd.argoproj.io/sync-wave: "0"
---
# Wave 1: only after the app is healthy, apply the ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop-api
  annotations:
    argocd.argoproj.io/sync-wave: "1"
Wave use case Typical wave order Why
CRDs / namespaces -2 to -1 Must exist before anything that references them
Secrets / ConfigMaps -1 to 0 App pods need config at start
Core workloads (Deployments) 0 The app itself
Service / Ingress / routes 1 Expose only a healthy app
Post-deploy jobs / warmup 2+ After traffic-ready

Resource hooks (Argo CD)

A hook is a resource (almost always a Job) annotated to run at a sync phase. The phases are PreSync (before applying the manifests — run DB migrations here), Sync (alongside the main apply), PostSync (after everything is applied and healthy — smoke tests, cache warm), and SyncFail (only if the sync failed — alert, rollback side-effects). A hook delete policy controls cleanup.

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrate
  annotations:
    argocd.argoproj.io/hook: PreSync                 # run before the app updates
    argocd.argoproj.io/hook-delete-policy: HookSucceeded  # clean up on success
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ghcr.io/acme/shop-api:v2.4.1
          command: ["./migrate", "up"]
Hook phase Runs when Classic job If it fails
PreSync Before manifests are applied DB schema migration, backup Sync aborts; app not updated
Sync During the apply Custom apply logic, wave-coupled tasks Sync marked failed
PostSync After apply + healthy Smoke test, cache warm, notify Sync marked failed (app already updated)
SyncFail Only on a failed sync Alert, compensating cleanup n/a
Skip Never (skip this object) Exclude a resource from sync n/a
Hook delete policy Deletes the hook Job… Use when
HookSucceeded After it succeeds The common case (keep failures for debugging)
HookFailed After it fails You clean up failures elsewhere
BeforeHookCreation Before re-creating on next sync Idempotent re-runs (delete old, run new)

A migration deadlock to avoid: a PreSync migration Job that itself needs the new app’s config (a ConfigMap applied in the main Sync) will hang — the config isn’t applied yet. Keep PreSync jobs self-contained (own image, own env, own DB connection) and don’t make them depend on objects that only land during Sync.

Flux ordering: dependsOn and health gates

Flux expresses ordering differently — between Kustomizations via dependsOn and health checks (wait: true + healthChecks), and within Helm via real Helm hooks (helm-controller honors pre-install/post-upgrade hooks natively, an advantage over Argo CD’s helm template default).

# infra must be healthy before apps reconcile
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata: { name: apps, namespace: flux-system }
spec:
  interval: 5m
  dependsOn:
    - name: infra                  # wait for the 'infra' Kustomization first
  sourceRef: { kind: GitRepository, name: gitops }
  path: ./apps
  wait: true                       # block on health
  healthChecks:
    - apiVersion: apps/v1
      kind: Deployment
      name: shop-api
      namespace: shop
Ordering need Argo CD mechanism Flux mechanism
Resource A before resource B in one app sync waves dependsOn between Kustomizations / wave-like split
App A before app B sync waves across child apps Kustomization.dependsOn
Run a migration before deploy PreSync hook Job Helm pre-upgrade hook / separate Kustomization
Run a test after deploy PostSync hook Job post-deploy Kustomization + healthChecks
Wait for health before next stage wave health gating wait: true + healthChecks

Multi-cluster GitOps

At fleet scale the question becomes topology: one control plane managing many clusters (hub-and-spoke) versus each cluster running its own agent (standalone). The choice drives blast radius, credential flow, and how you generate per-cluster config.

Topology How it works Pros Cons
Hub-and-spoke (Argo CD) One Argo CD manages N registered remote clusters Single pane of glass; ApplicationSet cluster generators; central RBAC Hub holds creds to all clusters (blast radius); hub is a SPOF
Standalone agent (Flux idiomatic) Each cluster runs its own Flux watching its own path Strong isolation; no cross-cluster creds; cluster failure is contained No single UI; consistency is by convention/automation
Argo CD per cluster An Argo CD instance in each cluster Isolation + UI per cluster N control planes to operate/upgrade
Hub with agent (Argo CD “agent” mode) Central UI, but agents pull locally UI without central creds to data planes Newer; more moving parts

The credential-direction trade-off is the crux. Hub-and-spoke means the hub stores a kubeconfig/ServiceAccount token for every spoke — convenient and centrally visible, but a compromised hub can touch every cluster. Standalone agents store no cross-cluster credentials; each cluster pulls only its own config, so the blast radius of any one compromise is one cluster. Regulated and high-isolation environments usually choose standalone (or hub-with-agent) for exactly this reason; teams optimizing for a single operator view choose hub-and-spoke.

Registering a remote cluster with the Argo CD hub:

# Add a spoke cluster to the Argo CD hub (creates a ServiceAccount + stores creds)
argocd cluster add prod-eu-context --name prod-eu --label env=prod --label region=eu

# Now an ApplicationSet cluster generator (env=prod) targets it automatically
argocd cluster list

Per-cluster differences (region, replica counts, hostnames, feature flags) are the real multi-cluster challenge. Don’t fork the whole manifest per cluster; layer them:

Per-cluster variation strategy Mechanism Best for
Kustomize overlays per cluster overlays/prod-eu, overlays/prod-us Structural differences (resources added/removed)
Helm values per cluster values-prod-eu.yaml Parameterized differences (counts, hosts)
ApplicationSet template params {{.region}}, {{.name}} from generator Generated, DRY per-cluster apps
Flux post-build substitution ${cluster_region} from a ConfigMap/Secret Variable injection without templating engine
Config files (Git files generator) One YAML per cluster drives params Config-as-data per cluster

A clean fleet repo layout that scales:

gitops/
├── clusters/
│   ├── prod-eu/                  # what this cluster runs (Flux Kustomizations / Argo apps)
│   ├── prod-us/
│   └── staging/
├── infrastructure/              # add-ons: ingress, cert-manager, monitoring
│   ├── base/
│   └── overlays/{prod,staging}/
└── apps/                        # the actual workloads
    ├── base/
    └── overlays/{prod-eu,prod-us,staging}/

Secrets in GitOps: the three real answers

GitOps has one notorious gap: you must not commit plaintext secrets to Git, yet Git is your source of truth. There are exactly three production-grade patterns, and choosing among them is a real decision with real trade-offs. (A base64-encoded Secret is not encryption — base64 is encoding; anyone with repo read access trivially decodes it. Never do this.)

Approach How it works What’s in Git Decryption / fetch happens Key/secret store
SOPS (+ age/KMS) Encrypt secret values before commit; controller decrypts at apply Encrypted YAML (values ciphered, keys readable) In-cluster (Flux native; Argo via plugin) age key / cloud KMS / Vault
Sealed Secrets Encrypt a Secret to a SealedSecret only the cluster controller can open SealedSecret CRD (ciphertext) In-cluster controller unseals → Secret Controller’s private key (per cluster)
External Secrets Operator (ESO) Store secrets in an external manager; sync into Secrets ExternalSecret reference (no secret data) ESO fetches from the external store Azure Key Vault / AWS SM / GCP SM / Vault

SOPS

SOPS (Secrets OPerationS) encrypts the values of a YAML/JSON file with a key from age, PGP, or a cloud KMS, leaving the keys readable so diffs are meaningful. You commit the encrypted file; the controller decrypts it in-cluster before applying. Flux’s kustomize-controller has native SOPS decryption; Argo CD does it via a config-management plugin (e.g. ksops).

# secret.enc.yaml — committed to Git; only values are ciphertext
apiVersion: v1
kind: Secret
metadata:
  name: shop-db
  namespace: shop
stringData:
  DB_PASSWORD: ENC[AES256_GCM,data:9f3a...,iv:...,tag:...,type:str]
sops:
  age:
    - recipient: age1q9...        # the cluster's age public key
      enc: |
        -----BEGIN AGE ENCRYPTED FILE-----
        ...
        -----END AGE ENCRYPTED FILE-----
# Tell Flux to decrypt with the cluster's age key (mounted as a Secret)
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata: { name: apps, namespace: flux-system }
spec:
  interval: 5m
  sourceRef: { kind: GitRepository, name: gitops }
  path: ./apps
  decryption:
    provider: sops
    secretRef:
      name: sops-age            # holds the age private key

Sealed Secrets

Sealed Secrets (Bitnami) gives each cluster a controller with a private key. You encrypt a normal Secret into a SealedSecret CRD using the cluster’s public key (kubeseal); only that cluster’s controller can decrypt it. The SealedSecret is safe in Git; the controller unseals it into a real Secret at runtime.

# Encrypt a Secret to a SealedSecret using the cluster's public cert
kubectl create secret generic shop-db --namespace shop \
  --from-literal=DB_PASSWORD='s3cr3t' --dry-run=client -o yaml \
  | kubeseal --controller-namespace kube-system --format yaml > sealed-shop-db.yaml
# Commit sealed-shop-db.yaml — it's encrypted to THIS cluster only

External Secrets Operator

ESO keeps the secret outside Git entirely — in Azure Key Vault, AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault — and commits only a reference. The operator fetches the value and projects it into a Kubernetes Secret, refreshing on an interval. Git never contains the secret in any form, encrypted or not.

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: shop-db
  namespace: shop
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: azure-kv               # points at a SecretStore (Key Vault + workload identity)
    kind: SecretStore
  target:
    name: shop-db                # the K8s Secret ESO will create
  data:
    - secretKey: DB_PASSWORD
      remoteRef:
        key: shop-db-password    # the secret name in Key Vault

Choosing among them

If you… Pick Why
Want secrets versioned in Git with full audit and rotation via PR SOPS Encrypted values live in Git; diff-friendly; KMS-backed
Want the simplest “encrypt to this cluster” with no external dependency Sealed Secrets One controller, per-cluster key, no cloud secret store needed
Already run a cloud secret manager / Vault and want a single source for all apps ESO Secret never touches Git; central rotation; one store of record
Are on Flux and want zero extra plugins SOPS Native decryption in kustomize-controller
Need cross-team secret governance and rotation policy ESO The external store enforces RBAC/rotation centrally
Approach Rotation story Key blast radius Multi-cluster Extra components
SOPS Re-encrypt + PR (or rotate KMS key) KMS/age key compromise = all secrets Same encrypted file works if all clusters share recipients None (Flux) / plugin (Argo)
Sealed Secrets Re-seal with kubeseal + PR Per-cluster key (compromise = one cluster) Re-seal per cluster (keys differ) Sealed-secrets controller
ESO Rotate in the external store (no Git change) External store IAM is the boundary One store, all clusters reference it ESO + cloud store/Vault

The deeper point for all three: GitOps stores the reference or ciphertext, never the plaintext, and the cluster (or the operator) holds the decryption capability. This preserves the GitOps property (everything is in Git) without the catastrophe of plaintext credentials in version history — which, once pushed, are effectively leaked forever even after deletion.

Progressive delivery on top of GitOps

GitOps gets the desired manifests onto the cluster; it does not, by itself, do canary or blue-green rollouts with metric-based automated rollback. That’s progressive delivery, and it composes on top of GitOps via two sibling projects: Argo Rollouts (pairs with Argo CD) and Flagger (pairs with Flux). The key insight: the Rollout/Canary definition itself lives in Git — so your release strategy is also versioned, reviewed, and reconciled.

Concern Plain GitOps GitOps + progressive delivery
Get new manifests live Yes Yes (unchanged)
Shift traffic gradually (canary) No Yes (Rollouts/Flagger steps)
Analyze metrics, auto-rollback on SLO breach No Yes (AnalysisTemplate / metric checks)
Blue-green with manual gate No (just replace) Yes (preview service + promote)
Where the strategy is defined n/a In Git (a Rollout / Canary CRD)

Argo CD manages a Rollout (a drop-in Deployment replacement) the same way it manages any resource — but the Rollout controller then drives the canary steps and consults an AnalysisTemplate (Prometheus/Datadog queries) to decide promote-or-abort:

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: shop-api
  namespace: shop
spec:
  replicas: 6
  strategy:
    canary:
      steps:
        - setWeight: 10          # send 10% of traffic to the new version
        - pause: { duration: 5m }
        - analysis:              # query metrics; abort if they breach
            templates: [{ templateName: success-rate }]
        - setWeight: 50
        - pause: { duration: 5m }
        - setWeight: 100
  selector: { matchLabels: { app: shop-api } }
  template:
    metadata: { labels: { app: shop-api } }
    spec:
      containers:
        - name: api
          image: ghcr.io/acme/shop-api:v2.4.1

For Flux, Flagger watches a normal Deployment and a Canary CRD; on each image change it spins up a canary, ramps traffic via your service mesh/ingress, runs metric checks, and promotes or rolls back — all reconciled by Flux.

Tool Pairs with Traffic shaping via Metric sources Rollback trigger
Argo Rollouts Argo CD SMI, Istio, NGINX, ALB, Gateway API Prometheus, Datadog, Wavefront, Kayenta AnalysisTemplate failure
Flagger Flux Istio, Linkerd, NGINX, App Mesh, Gateway API Prometheus, Datadog, CloudWatch Metric threshold breach

The full pattern: CI builds and pushes the image and writes the new tag to Git; GitOps reconciles the new Rollout/Deployment; progressive delivery ramps traffic with metric gates and auto-rolls-back on breach — and that rollback is also just the controller reverting to the previous healthy state, which you can capture as a git revert. For the strategy mechanics, see Progressive Delivery and Feature Flags: Release Without Fear and the GitOps-specific deep dive Progressive Delivery: Canary, Blue-Green and Automated Rollback with GitOps.

Architecture at a glance

The first diagram shows the GitOps machine end to end. Read it left to right. A developer opens a pull request against the deployment repo (changing an image tag, a replica count, a config value); reviewers approve and merge to main. That commit is now the desired state in Git. The GitOps controller running inside the cluster — Argo CD’s application-controller (via repo-server) or Flux’s source- and kustomize-controllerpulls the repo, renders it through Kustomize or Helm, and diffs the result against the live cluster state. Where they differ, the controller server-side-applies the delta to the Kubernetes API server, which rolls out the change to workloads (Deployments, Services, Ingress). Notice the arrow directions: every credential-bearing connection points outward from the cluster (to Git and the registry) or inward to the local API server — nothing external holds cluster admin. The CI pipeline is off to the side: it builds the image, pushes it to the registry, and writes the new tag back to Git — it never touches the cluster directly.

GitOps delivery architecture: a developer opens a pull request to the deployment repo, reviewers merge to main making Git the desired state; an in-cluster GitOps controller (Argo CD application-controller via repo-server, or Flux source- and kustomize-controllers) pulls the repo, renders manifests through Kustomize or Helm, diffs against live cluster state, and server-side-applies the delta to the Kubernetes API server which rolls out Deployments, Services and Ingress; the CI pipeline builds and pushes the image to the registry and writes the new tag back to Git but never touches the cluster, and every credentialed connection points outward to Git/registry or inward to the local API server so no external system holds cluster admin

The second diagram zooms into the reconciliation loop and the drift case specifically. The controller runs a continuous cycle: observe the desired state (latest revision from Git) and the actual state (live objects via informers/cluster reads), diff them, and if they differ, act (apply) — then re-assess health and loop. The drift path is the interesting one: when an operator runs a manual kubectl edit or kubectl scale against the cluster, the actual state diverges from desired. The very next observe-diff detects the divergence and marks the app OutOfSync; with self-heal enabled, the act phase reverts the live object back to the Git-defined state, returning the loop to Synced. With self-heal off, the loop simply reports OutOfSync and waits for a human to sync — the divergence is visible but not auto-corrected. This is the difference between “GitOps that enforces” and “GitOps that only notifies,” and the diagram makes the branch explicit.

GitOps reconciliation loop: the in-cluster controller continuously observes desired state (latest Git revision) and actual state (live cluster objects), computes a three-way diff, and when they match stays Synced; when an operator makes a manual kubectl edit or scale the actual state drifts from desired, the next diff marks the application OutOfSync, and with self-heal enabled the controller reverts the live object back to the Git-defined desired state to return to Synced, whereas with self-heal off it only reports the drift and waits for a human to trigger a sync

Real-world scenario

Northwind Logistics runs a freight-tracking platform on Kubernetes: 38 microservices across four AKS clustersprod-eu, prod-us, staging, and dev — operated by an eight-person platform team. Before GitOps, each cluster was deployed by a per-cluster Azure Pipelines stage running helm upgrade with cluster admin credentials stored as pipeline secrets. The control planes were public (the SaaS runner needed to reach them). Drift was rampant: prod-eu had ingress-nginx 1.9 while prod-us had 1.7 because a chart bump only ran in one region; three services had replicas hand-bumped during past incidents and never reverted; and “what’s in prod?” required kubectl get against each cluster.

The trigger to change was an audit finding plus an outage. A junior engineer, paging at 2 a.m., ran kubectl set image directly on prod-us to ship a hotfix faster than the pipeline. It worked — and then the next scheduled pipeline run, an hour later, redeployed the old image from Git, silently reverting the fix and re-breaking the service. Forty-five minutes of confusion followed because nobody connected the redeploy to the regression. The lesson was stark: with no continuous reconciliation, the cluster and the repo were two separate truths, and whichever wrote last won.

They adopted Argo CD in a hub-and-spoke topology (one Argo CD instance managing all four clusters) for the application teams who wanted the UI, plus Flux on the cluster-add-on layer (ingress, cert-manager, monitoring) where the platform team wanted everything headless and CRD-driven. The deployment repo was restructured: apps/ with Kustomize bases and per-cluster overlays, infrastructure/ for add-ons, and a bootstrap/ app-of-apps. An ApplicationSet with a Cluster generator deployed the platform add-ons to every cluster labelled env=prod, so a chart bump now hit prod-eu and prod-us from one PR — ending the version-skew problem permanently. Secrets moved to SOPS with an Azure KMS key per environment (Flux decrypts in-cluster), so the database passwords lived in Git as ciphertext, rotatable by PR.

The migration ran in waves over six weeks: dev first (self-heal off, observe drift for two weeks to build trust and find which fields fought controllers — they discovered three HPAs and the Istio sidecar injector needed ignoreDifferences), then staging, then the two prod clusters with self-heal and prune on but PruneLast=true. The AKS API servers were switched to private clusters — possible only because the SaaS runner no longer needed inbound access; Argo CD pulled from inside.

The payoff was concrete. The 2-a.m. hotfix scenario reversed: a manual kubectl edit on prod-us was now reverted by Argo CD within ~20 seconds (informer-driven self-heal), forcing the right behavior — open a PR. Rollback of a bad release dropped from a 45-minute pipeline re-run to a git revert that reconciled in under a minute. Drift across the fleet went to zero and stayed there; “what’s running in prod-eu?” became “read clusters/prod-eu on main.” An auditor’s “prove every production change was reviewed and attributable” was satisfied by git log and the PR history. The platform spend was unchanged — GitOps added two small controller deployments per cluster — but the mean time to recover for deployment incidents fell roughly 70%, and the audit risk of public control planes was eliminated. The sentence the team painted on the wall: “The cluster is a follower of Git. If you want to change prod, change the repo — the cluster will catch up, and if you don’t, it’ll undo you.”

The migration as a timeline, because the order is the lesson:

Phase Action Self-heal / prune What it proved
Week 1–2 Onboard dev; observe drift only Off / off Found which fields fight controllers (HPA, Istio)
Week 3 Add ignoreDifferences; enable self-heal on dev On / off Self-heal stable once field ownership is right
Week 4 staging with self-heal + PruneLast On / on (last) Prune is safe with guardrails
Week 5 prod-us then prod-eu On / on (last) Manual edits auto-revert in ~20 s
Week 6 Private AKS API servers; SOPS secrets; ApplicationSet add-ons On / on Pull model enables private control planes; version skew gone

Advantages and disadvantages

GitOps is a strong default for Kubernetes delivery, but it is not free of cost or sharp edges. Weigh it honestly:

Advantages Disadvantages
Git is the audit log — every change is a reviewed, attributable, revertible commit Kubernetes-centric — the mature tooling targets K8s; non-K8s estate needs other tools
Continuous drift correction — the cluster can’t silently diverge from intent Self-heal fights other controllers if you declare fields you don’t own (HPA, webhooks)
Pull model — no external system holds cluster creds; API servers can be private Learning curve — CRDs, reconcile semantics, waves/hooks, sync policies are non-trivial
Trivial rollbackgit revert reconciles to the previous state in seconds Prune is dangerous — a bad render with prune on can delete production objects
DRY multi-cluster — ApplicationSets/overlays manage fleets from one source Secrets need extra tooling — SOPS/Sealed/ESO is mandatory, not optional
Separation of duties — CI builds artifacts; CD is a Git change app teams can review Two sources of latency — webhook + interval; mis-tuned, deploys feel slow or drift lingers
Reproducible environments — the repo is the environment; rebuild a cluster from Git Debugging is indirect — you debug a controller’s view, not a direct kubectl apply
Strategy-as-code — canary/blue-green live in Git via Rollouts/Flagger Operational surface — more controllers to run, upgrade, and monitor

GitOps is right for teams running Kubernetes at real scale who want auditability, drift safety, and a private control plane — which is most production Kubernetes. It is overkill for a single small cluster with one or two engineers, where a simple kubectl apply in CI is plenty and the CRD/operator overhead isn’t justified. It is a poor fit as your only delivery model if much of your estate is non-Kubernetes (VMs, serverless, managed PaaS) — there, GitOps covers the K8s slice and you use the platform’s own IaC/CD for the rest. And it is not a substitute for testing: GitOps faithfully deploys whatever you merged, including a broken manifest — the gate is your PR review and CI, not the reconciler.

Hands-on lab

Stand up Argo CD on a local cluster, deploy an app from Git, watch self-heal revert a manual change, then roll back via Git. Free-tier-friendly (a local kind or minikube cluster; no cloud spend). Run in a Bash shell with kubectl, helm/kustomize, and git available.

Step 1 — Create a local cluster and install Argo CD.

kind create cluster --name gitops-lab
kubectl create namespace argocd
kubectl apply -n argocd -f \
  https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl -n argocd rollout status deploy/argocd-server --timeout=180s

Expected: the argocd-* pods reach Running; argocd-server rollout completes.

Step 2 — Get the admin password and the CLI logged in.

# Initial admin password is stored in a secret
ARGOCD_PWD=$(kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath='{.data.password}' | base64 -d)
echo "admin password: $ARGOCD_PWD"

# Port-forward the UI/API and log in with the CLI
kubectl -n argocd port-forward svc/argocd-server 8080:443 >/tmp/pf.log 2>&1 &
sleep 3
argocd login localhost:8080 --username admin --password "$ARGOCD_PWD" --insecure

Expected: 'admin:login' logged in successfully. The UI is at https://localhost:8080.

Step 3 — Create an Application pointing at the public guestbook sample.

argocd app create guestbook \
  --repo https://github.com/argoproj/argocd-example-apps.git \
  --path guestbook \
  --dest-server https://kubernetes.default.svc \
  --dest-namespace default \
  --sync-policy automated --self-heal --auto-prune
argocd app get guestbook

Expected: the app appears with Sync Status: OutOfSync then Synced, Health: Healthy once pods start.

Step 4 — Confirm the deployed state.

kubectl get deploy,svc -l app.kubernetes.io/instance=guestbook
argocd app get guestbook --refresh

Expected: a guestbook-ui Deployment with the replica count defined in Git (1), Synced/Healthy.

Step 5 — Introduce drift and watch self-heal revert it.

# Manually scale the deployment OUT of band — this is "drift"
kubectl scale deploy guestbook-ui --replicas=5
# Watch Argo CD detect OutOfSync and self-heal back to the Git value (1)
watch -n 2 'kubectl get deploy guestbook-ui -o jsonpath="{.spec.replicas}"; echo'

Expected: replicas briefly show 5, then Argo CD reverts them to 1 within seconds (informer-driven self-heal). The app momentarily goes OutOfSync then back to Synced. This is the core GitOps property in action — the cluster follows Git, not your manual edit.

Step 6 — Roll back via Git semantics (history + sync).

# Argo CD tracks sync history; view it
argocd app history guestbook
# Roll back to a previous synced revision by ID (simulates 'git revert' + reconcile)
argocd app rollback guestbook <REVISION_ID>
argocd app get guestbook

Expected: the app re-syncs to the chosen historical revision. In a real repo this is git revert <sha> && git push, after which Argo CD reconciles automatically.

Validation checklist. You installed an in-cluster controller, deployed an app whose source of truth is a Git repo, proved self-heal by reverting a manual kubectl scale, and rolled back via history. No kubectl apply of the app itself ever happened from your shell — the controller did it. The lab steps mapped to what each proves:

Step What you did What it proves
1–2 Install Argo CD in-cluster, log in The controller runs inside the cluster (pull model)
3 Create an Application from a Git repo Git is the source of truth; the app is declarative
5 Scale out of band, watch revert Drift detection + self-heal enforce desired state
6 Roll back via history Rollback is a Git-history operation, fast and exact

Cleanup.

argocd app delete guestbook --yes
kind delete cluster --name gitops-lab
# stop the port-forward
kill %1 2>/dev/null || true

Cost note. Entirely local (kind/minikube) — zero cloud spend. On a managed cluster, Argo CD itself is a handful of small pods; the cost is the cluster you already run.

Common mistakes & troubleshooting

This is the playbook you bookmark. First a scannable table, then the worst offenders expanded.

# Symptom Root cause Confirm (exact cmd / UI path) Fix
1 App flaps SyncedOutOfSync; replicas ping-pong Self-heal fighting an HPA/webhook over a field you declared argocd app diff <app> shows /spec/replicas; check HPA exists Don’t declare the field; add ignoreDifferences for that path
2 ComparisonError / app won’t render Bad Kustomize/Helm (missing file, bad values, wrong path) argocd app get <app>; repo-server logs; kustomize build <path> locally Fix the manifest/path; validate render in CI before merge
3 App stuck Progressing, never Healthy A resource never reaches health (CrashLoop, bad image, failed hook) kubectl get pods; argocd app get health tree; pod logs Fix the workload (image tag, config, hook); check ImagePullBackOff
4 Prune deleted production objects Render produced empty/fewer manifests with prune on Argo CD events; Git diff that emptied a path Restore from Git; use PruneLast, prune:false on roots, validate renders
5 New image built but not deployed No webhook + long interval, or tag not written to Git argocd app get revision vs latest commit; check CI tag write-back Wire Git webhook; ensure CI commits the new tag; argocd app sync
6 Secret empty → app crash-loops SOPS/Sealed/ESO decryption or fetch failed kustomize-controller logs (decrypt error); kubectl get secret empty Fix key/KMS access; reseal; check ESO SecretStore auth
7 Sync hangs on a PreSync hook Migration Job needs config that lands only during Sync kubectl get job; hook Job pod logs Make PreSync self-contained; don’t depend on Sync-phase objects
8 ApplicationSet created/deleted dozens of apps A template/generator typo fanned out kubectl get applications -A; ApplicationSet controller logs Revert the generator; use preserveResourcesOnDeletion; review fan-out
9 App OutOfSync forever, diff shows server-default fields Diffing fields the API server defaults/mutates argocd app diff shows defaulted fields (e.g. status, CA bundles) ignoreDifferences; exclude managed fields
10 Sync fails: namespace not found Target namespace doesn’t exist and isn’t created Sync error “namespaces … not found” CreateNamespace=true syncOption (or wave the namespace first)
11 Wrong cluster/namespace got the app destination server/namespace misconfigured argocd app get destination; argocd cluster list Correct destination; verify cluster registration/labels
12 Helm hooks behave differently than expected (Argo CD) Argo CD renders helm template (no real Helm lifecycle) App shows Helm hooks as plain resources Use Argo CD hooks/waves, or --helm native, or use Flux helm-controller
13 Reconcile slow / controller OOM on a huge app Massive app/repo overwhelms repo-server/controller controller/repo-server memory; reconcile duration metric Split into smaller apps; raise resources; cache; shard controllers
14 Out-of-band hotfix keeps getting reverted Self-heal reverting a manual fix that isn’t in Git App goes OutOfSync right after the manual change Put the fix in Git (PR) first; then it’s desired state, not drift

The expanded form for the two that bite hardest:

Prune deleted production objects (#4). With prune: true, a render that produces an empty or reduced manifest set — a Kustomize error, a deleted file, a path typo pointing at an empty directory — makes the controller conclude those objects were removed from Git and delete them from the cluster. Confirm: application events show Pruned/Deleted for resources you didn’t intend to remove, and the Git diff emptied a path or broke the render. Fix: re-sync the corrected manifests to restore. Prevent recurrence with PruneLast=true, prune: false on the bootstrap/root app, CI render validation (kustomize build must produce the expected object count), and resource-exclusion for critical objects. Treat prune as rm -rf with a loaded source.

Secret is empty and the app crash-loops (#6). The secrets layer failed silently — SOPS couldn’t decrypt (missing age/KMS key, or KMS IAM denied), Sealed Secrets can’t unseal (wrong cluster’s key), or ESO can’t fetch (bad SecretStore auth / Key Vault RBAC). The Secret ends up empty or missing, the app reads a blank value, and it crash-loops — looking like an app bug. Confirm: kubectl get secret <name> -o yaml shows no data or a missing resource; the relevant controller logs show a decrypt/fetch error (kustomize-controller for SOPS, the sealed-secrets controller, or external-secrets). Fix: restore the controller’s access to the key/store (KMS IAM, age key Secret, Key Vault role assignment), or re-seal/re-encrypt with the correct key. Never “fix” it by committing a plaintext secret — that leaks it into history permanently.

Best practices

The signals worth alerting on before the next incident:

Alert on Signal Why it’s leading
App OutOfSync for too long Argo CD app_sync_status / Flux not-ready Drift not healing, or a stuck sync
Sync/reconcile failures argocd_app_sync_total{phase=Failed} / Flux events A bad merge or render is failing to apply
ComparisonError Argo CD app condition Manifests don’t render — deploys are blocked
Reconcile latency rising controller reconcile duration metric Repo/app too large; controller under-resourced
Prune/delete events application events Catch an unintended deletion fast
Secret decrypt/fetch errors kustomize/ESO controller logs Secrets layer broke before apps crash-loop

Security notes

The security controls mapped to what they prevent:

Control Mechanism Prevents
Private API server + pull model No inbound cluster access Compromised CI runner touching prod
Bounded AppProject / namespaced RBAC Per-tenant repo/cluster/ns allowlist A team deploying outside its blast radius
Signed-commit verification GPG verify on the source Unattributable/unauthorized revisions reconciling
SOPS / Sealed / ESO Ciphertext or reference in Git Secret leakage via version history
SSO + RBAC on the GitOps UI OIDC + role bindings Unauthorized sync/rollback/exec
Branch protection on deploy repo Required reviews/checks Unreviewed production changes
Image digest pinning + scanning Digest refs + CI scan Tampered/unknown images reconciling

Cost & sizing

GitOps adds little to the bill — its cost is operational, not infrastructural. The components are a few small controller Pods per cluster; the spend you already have is the cluster.

A rough sizing picture and what drives each cost:

Item Footprint / cost Drives the cost Watch-out
Argo CD control plane ~4 small pods; few hundred mCPU / few hundred MB–2 GB Number & size of apps; render load repo-server OOM on huge Helm charts
Flux controllers Several small pods; modest CPU/MEM Number of sources/Kustomizations; intervals Too-short intervals → API-server/Git load
Hub cross-cluster creds No spend; operational risk Number of spokes Hub compromise = fleet blast radius
ESO + cloud secret manager Per-secret/op charge (small) Secret count + refresh interval Frequent refresh = more API calls/cost
SOPS + KMS KMS key + decrypt calls (small) Number of encrypted files/reconciles Key access must be tightly scoped
Sealed Secrets Free (in-cluster key) n/a Per-cluster key; re-seal per cluster

Interview & exam questions

1. What are the four GitOps principles, and which one distinguishes GitOps from “deploy on merge”? Declarative, versioned & immutable, pulled automatically, and continuously reconciled. The fourth — continuous reconciliation — is what distinguishes true GitOps: it re-asserts desired state on a schedule regardless of whether anything merged, so a manual kubectl edit is reverted even with no new commit. “Deploy on merge” only acts on a webhook and never re-checks the cluster, so it doesn’t correct drift.

2. Why is the pull model more secure than push-based CD? In pull-based GitOps the controller runs inside the cluster and reaches out to Git/registry, so no external system needs cluster admin credentials and the API server can be private. Push CD requires the (often SaaS) runner to hold a powerful kubeconfig and reach the control plane, expanding the attack surface to “compromise the runner, own the cluster.”

3. Explain drift detection vs self-heal vs prune. Drift detection computes and reports the diff between live and desired (always on, read-only). Self-heal automatically reverts live drift back to Git. Prune deletes live objects that no longer exist in Git. They’re independent policies — you can detect without healing, and heal without pruning — and prune is the dangerous one because a bad render can delete production objects.

4. An app flaps OutOfSync and replicas keep changing. Diagnose. Self-heal is fighting another controller (almost always an HPA managing replicas, or a mutating webhook) over a field you declared in Git. Fix by not declaring the field (let the HPA own replicas) and adding ignoreDifferences for that path so the diff stays clean. The rule: own the fields you set, ignore the fields other controllers set.

5. Compare Argo CD and Flux at a high level. Argo CD is an all-in-one, application-centric platform with a first-class UI, the Application/ApplicationSet model, and a hub multi-cluster pattern. Flux is the GitOps Toolkit — composable single-responsibility controllers (source/kustomize/helm/image), CLI-and-CRD-first with no built-in UI, native SOPS decryption, real Helm releases, and idiomatic agent-per-cluster. Choose Argo CD for UI/self-service and central multi-cluster; Flux for headless platform automation, native Helm, and in-cluster image automation.

6. What is the app-of-apps pattern and when do you outgrow it? A single root Application points at a directory of more Application manifests, so syncing the root bootstraps all children — GitOps managing GitOps. You outgrow it when you need the same app across many clusters or per-directory generation, because app-of-apps is static (per-cluster edits are manual). That’s when you move to ApplicationSets with generators.

7. What problem do sync waves and resource hooks solve? Ordering. A sync applies many objects but has dependencies: CRDs/namespaces before their consumers, a DB migration before the new app (PreSync hook), ingress after the app is healthy (later wave), a smoke test after deploy (PostSync hook). Sync waves order resources within a sync; hooks run Jobs at phase boundaries (PreSync/Sync/PostSync/SyncFail).

8. How do you handle secrets in GitOps? Never commit plaintext (base64 is not encryption). Three patterns: SOPS (encrypt values in Git, controller decrypts in-cluster via age/KMS — native in Flux), Sealed Secrets (encrypt to a per-cluster controller key; only that cluster can unseal), and External Secrets Operator (keep secrets in Key Vault/Secrets Manager/Vault, commit only a reference). Git holds ciphertext or a reference; the cluster/operator holds the decryption capability.

9. How does GitOps relate to progressive delivery? GitOps gets the desired manifests onto the cluster but doesn’t itself do canary/blue-green with metric-based rollback. Argo Rollouts (with Argo CD) and Flagger (with Flux) add that — and crucially the Rollout/Canary definition lives in Git, so the release strategy is versioned and reconciled too. CI builds the image, GitOps reconciles it, progressive delivery ramps traffic with metric gates and auto-rolls-back.

10. Why can enabling prune delete your production app, and how do you prevent it? If a render produces an empty or reduced manifest set (a Kustomize error, a deleted file, a path typo), the controller concludes those objects were removed from Git and deletes them. Prevent it with PruneLast=true, prune: false on root/bootstrap apps, CI render validation (assert the expected object count), and resource-exclusion for critical objects. Treat prune like rm -rf with a loaded source.

11. What’s the difference between hub-and-spoke and standalone-agent multi-cluster, and what’s the security trade-off? Hub-and-spoke runs one control plane (e.g. Argo CD) that holds credentials to many registered clusters — single pane of glass, but the hub is a SPOF and its compromise reaches every cluster. Standalone agents (idiomatic Flux) run one controller per cluster watching only its own config, holding no cross-cluster credentials — strong isolation, contained blast radius, but no single UI. Regulated/high-isolation estates lean standalone.

12. A new image was built but isn’t running in the cluster. What do you check? Whether the new tag was written to the deployment repo (CI write-back), and whether a webhook fired or you’re waiting on the poll interval. Confirm with argocd app get (deployed revision vs latest commit) or Flux’s reconcile status. Fix by ensuring CI commits the tag, wiring a Git webhook, or forcing argocd app sync / flux reconcile. GitOps only deploys what’s in Git — if CI didn’t commit the tag, nothing changed.

These map to CKA/CKAD (Kubernetes objects, controllers, RBAC), the Argo CD / Flux project knowledge expected in platform-engineering interviews, and cloud certs where the deployment target lives — AZ-204 (deploy to AKS), AZ-400 (DevOps, release strategies, secrets), and the secrets angle touches AZ-500. A compact cert mapping:

Question theme Primary focus Exam relevance
GitOps principles, pull vs push Concept / architecture AZ-400, platform-engineering interviews
Reconciliation, drift, self-heal Controller mechanics CKA/CKAD, Argo CD/Flux knowledge
Argo CD vs Flux, ApplicationSets Tooling decision Platform-engineering interviews
Secrets (SOPS/Sealed/ESO) Secure delivery AZ-400 / AZ-500
Progressive delivery integration Release strategy AZ-400
Multi-cluster topology & security Architecture AZ-400, cloud-specific (AKS/EKS/GKE)

Quick check

  1. Your “GitOps” pipeline only acts on a webhook when something merges and never re-checks the cluster. Which GitOps principle is it violating, and what capability do you lose?
  2. An app is flapping SyncedOutOfSync and the deployment’s replicas keeps changing. What is almost certainly happening, and what are the two fixes?
  3. True or false: committing a base64-encoded Secret to Git is an acceptable way to store secrets in GitOps.
  4. You enabled prune: true and a chunk of production disappeared after a merge. What likely happened, and name two guardrails that prevent it.
  5. You build a new image in CI, but it never deploys. Name two things to check.

Answers

  1. It violates continuous reconciliation (principle 4) — it’s automated push CD triggered by Git, not GitOps. You lose drift correction: a manual kubectl edit made when nothing merged is never detected or reverted, so the cluster silently diverges from intent between deploys.
  2. Self-heal is fighting another controller — almost always an HPA (or a mutating webhook) that owns replicas, which you also declared in Git. The two fixes are: (a) stop declaring replicas in the manifest so the HPA owns it, and (b) add ignoreDifferences for /spec/replicas so the diff stays clean.
  3. False. Base64 is encoding, not encryption — anyone with repo read access decodes it instantly, and once pushed it’s in history forever. Use SOPS, Sealed Secrets, or External Secrets Operator so Git holds ciphertext or a reference only.
  4. A render produced an empty or reduced manifest set (Kustomize error, deleted file, or a path typo pointing at an empty directory), so the controller concluded those objects were removed from Git and deleted them. Guardrails: PruneLast=true, keep prune: false on root/bootstrap apps, and validate renders in CI (assert the expected object count) before merge.
  5. Check (a) whether CI wrote the new image tag to the deployment repo (the desired state must actually change in Git) and (b) whether a webhook fired or you’re waiting on the poll interval — confirm the deployed revision vs the latest commit with argocd app get / flux status, and force a sync if needed.

Glossary

Next steps

You can now run GitOps in production — reconcile from Git, correct drift, manage a fleet, handle secrets, and layer on canaries. Build outward:

DevOpsGitOpsArgo CDFluxKubernetesKustomizeHelmProgressive Delivery
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading