GitOps is a simple idea wrapped in a lot of marketing. The simple idea: the desired state of your cluster lives in Git, and a controller in the cluster makes the cluster match Git. Reconciliation, not push.

This post explains how Argo CD and Flux actually do that, the patterns that scale past one team, and the gotchas that don’t make the conference talks.

The principle

Old way (CI-driven push):

git push → CI builds → CI runs `kubectl apply` → cluster changes

GitOps (controller pull):

git push → CI builds → manifest commits to Git
              cluster controller polls ──┘
              and reconciles to match

Two consequences:

  1. Git is the source of truth. Cluster state is a function of Git. You can recreate the cluster from Git.
  2. The controller closes the loop. If something drifts (someone kubectl edits a deployment), the controller un-drifts it.

Argo CD vs Flux

Argo CDFlux
Project age20182018 (v2 since 2021)
UIYes — best-in-classMinimal (Capacitor / Weave GitOps)
Multi-tenant modelProjects + AppProjectsTenancy via separate Kustomizations
CompositionApplication of applicationsKustomizations + HelmReleases
Declarative APICRDs (Application)CRDs (GitRepository, Kustomization, HelmRelease)
Pull or notificationDefault poll, optional webhookDefault poll, optional notifications
Where it shinesUI-driven workflows, multi-clusterPure GitOps, fine-grained RBAC, lighter

In 2026 my default is Argo CD for any team where humans interact with deployments, Flux for purely automated platforms or smaller setups. Both are excellent.

I’ll use Argo CD for examples; Flux equivalents are obvious once you see the shape.

The atom: an Application

An Argo CD Application says “this directory in this Git repo is the desired state of this thing in this cluster”:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: orders-api
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/example/orders-api
    path: k8s/overlays/prod
    targetRevision: HEAD
  destination:
    server: https://kubernetes.default.svc
    namespace: orders
  syncPolicy:
    automated:
      prune: true             # delete resources removed from Git
      selfHeal: true          # revert manual edits in the cluster
    syncOptions:
      - CreateNamespace=true
      - ServerSideApply=true

That’s the whole mental model. The rest is composition.

prune: true

If you delete a Deployment from your manifests and merge, Argo CD deletes it from the cluster. Without prune, deletions leak forever. Always turn this on.

selfHeal: true

If someone runs kubectl edit to “just fix it real quick,” Argo CD reverts within a minute. The drift is logged. This is GitOps’s killer feature: the cluster can’t secretly drift.

ServerSideApply=true

The right default in 2026. Server-side apply has cleaner conflict semantics and works correctly when multiple controllers manage parts of the same resource (Argo CD owns one block, an HPA controller owns another). Stop using client-side apply.

App-of-apps — managing many apps

You’ve got 50 services. You don’t want 50 hand-written Application YAMLs in 50 places. The pattern is app-of-apps: one Application whose source is a directory full of other Application manifests.

gitops/
├── apps/
│   ├── orders-api.yaml
│   ├── billing-api.yaml
│   ├── frontend.yaml
│   └── ...
└── root.yaml          ← single Application that points at apps/

Argo CD reconciles root, sees a directory of Application resources, applies them, and reconciles each. New service = add a YAML file, merge.

This is fine for 50 services. For 500, it gets noisy. That’s where ApplicationSets shine.

ApplicationSets — generated at scale

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: services
  namespace: argocd
spec:
  generators:
    - git:
        repoURL: https://github.com/example/gitops
        revision: HEAD
        directories:
          - path: services/*
  template:
    metadata:
      name: '{{.path.basename}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/example/gitops
        targetRevision: HEAD
        path: '{{.path}}'
      destination:
        server: https://kubernetes.default.svc
        namespace: '{{.path.basename}}'
      syncPolicy:
        automated: {prune: true, selfHeal: true}

This generates one Argo CD Application per directory under services/. Add a directory, get an Application. No template proliferation.

Generators include:

  • Git — directories or files in a repo (above)
  • Cluster — one Application per registered cluster (multi-cluster fan-out)
  • List — explicit list of values
  • Matrix — combine generators (e.g., per cluster × per service)

The matrix generator is how multi-cluster, multi-environment setups stay manageable.

Multi-environment

The cleanest pattern: environments are branches. Wrong. Environments are directories.

gitops/
├── base/                    ← shared Kustomize base
└── overlays/
    ├── dev/
    ├── staging/
    └── prod/

Each Application points at one overlay. Promote dev → staging → prod by copying or PRing a kustomization patch. Auditable, reviewable, reversible.

Why directories not branches:

  • Branches drift. Nobody knows which is “real.”
  • git diff overlays/staging overlays/prod answers “what’s different” instantly.
  • Promotion is a PR you can reject. Branch merges happen silently.

Multi-cluster

ApplicationSet’s cluster generator + a registered list of clusters:

spec:
  generators:
    - clusters:
        selector:
          matchLabels:
            env: prod
  template:
    spec:
      destination:
        server: '{{.server}}'
        namespace: orders
      source:
        repoURL: ...
        path: k8s/overlays/prod

One spec, deployed to every cluster matching env=prod. Add a cluster, deploy follows.

Helm — yes, but carefully

Both controllers handle Helm charts. You can do:

source:
  repoURL: https://example.github.io/charts
  chart: orders-api
  targetRevision: 1.4.2
  helm:
    values: |
      replicas: 3
      image:
        tag: v1.4.2

Two warnings:

  • Pin chart versions. targetRevision: HEAD on a Helm repo means surprise upgrades.
  • Don’t helm template outside Argo CD. Let Argo render and apply. Pre-rendered manifests defeat half of the value (drift detection on the rendered form, not the templated form, leaves you arguing about whitespace).

Secrets — the elephant

Plaintext secrets in Git is a non-starter. Three workable patterns:

1. SOPS (mozilla)

Encrypt YAML with KMS keys; commit the ciphertext. The cluster decrypts on apply (via helm-secrets, ksops, or a Flux SOPS-aware Kustomization).

2. Sealed Secrets (Bitnami)

The cluster generates a public key. You encrypt secrets with it client-side, commit SealedSecret, the controller decrypts in-cluster into real Secrets. Per-cluster keys, per-secret authorization.

3. External Secrets Operator (ESO)

Don’t store secrets in Git at all. Store references. ESO syncs values from AWS Secrets Manager / Vault / GCP Secret Manager into Kubernetes Secrets.

For platform setups in 2026, ESO is the default. Your secret store is the source of truth; Git only holds references and rotation policy. SealedSecrets is fine for smaller setups; SOPS works but is fiddly to teach.

Drift detection — the part that earns its keep

Argo CD compares live state to desired state continuously. Drift is highlighted in the UI; with selfHeal: true, it’s auto-corrected.

Application: orders-api
Sync Status: OutOfSync (Drift)
  - Deployment/orders-api: replicas live=5 desired=3

Two patterns to get right:

Some drift is intentional

HPAs change replicas. Cert-manager rewrites secrets. PVCs allocate dynamic storage IDs. Mark these fields as managed by other controllers using server-side apply field management or ignoreDifferences:

spec:
  ignoreDifferences:
    - group: apps
      kind: Deployment
      jsonPointers: ["/spec/replicas"]

Otherwise Argo CD will fight your HPA in an infinite loop.

Drift you don’t want

Manual kubectl edits, ad-hoc kubectl scale, well-meaning Friday afternoon fixes. With selfHeal, these revert. This is the pattern: discipline by tooling, not by memo.

CI/CD with GitOps

GitOps doesn’t replace CI. It changes what CI’s last step is.

StageToolOutput
Lint, testGitHub ActionsPass/fail
Build imageGitHub ActionsImage tag pushed
Update manifestGitHub ActionsPR or commit to gitops repo with new tag
ReconcileArgo CDCluster updated to match

The manifest-update step is the seam between CI and GitOps. Common patterns:

  • Image updater — Argo CD Image Updater watches the registry and updates manifests automatically.
  • Renovate — generic dependency-update bot, supports kustomization image tags.
  • CI-driven PR — your build pipeline opens a PR against the gitops repo. Reviewed and merged.

I prefer the third: explicit, auditable, gates promotion.

Gotchas worth knowing

CRD ordering

When applying CRDs and resources of those CRDs in the same sync, ordering matters. Use Sync Wave annotations:

metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "-1"   # CRDs first

Resource pruning order

Stateful workloads get killed first if you don’t think about prune order. Set argocd.argoproj.io/sync-options: Prune=false on critical resources, or use waves.

Webhook delays

Default poll is every 3 minutes. For tight feedback, configure GitHub/GitLab webhooks → Argo CD. Sync time drops to seconds.

Project quotas

Without AppProject quotas, a runaway template can deploy thousands of Applications. Always bound projects:

apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: team-orders
spec:
  sourceRepos: ["https://github.com/org/orders-*"]
  destinations:
    - server: https://kubernetes.default.svc
      namespace: "orders-*"
  clusterResourceWhitelist: []           # block cluster-scoped resources
  namespaceResourceWhitelist:
    - {group: "*", kind: "*"}

Drift on labels you didn’t write

Default labels Argo CD adds are owned by it. Other controllers (admission webhooks, OPA mutating policies) sometimes inject labels. With server-side apply, this is fine. With client-side, you’ll see eternal drift. Use server-side.

When not to use GitOps

  • Truly ephemeral environments where each is built imperatively and torn down (per-PR previews can go either way).
  • Edge devices that can’t poll a Git repo (use Flux’s notification controller or a different approach).
  • Workflows that are CI-driven by nature (one-off jobs, batch workloads).

For everything else — services, infra, configs, certs — GitOps wins.

Read this next

If you want a working GitOps repo with app-of-apps, ApplicationSets, multi-cluster, and ESO secrets wired up, it’s on rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .