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:
- Git is the source of truth. Cluster state is a function of Git. You can recreate the cluster from Git.
- The controller closes the loop. If something drifts (someone
kubectl editsa deployment), the controller un-drifts it.
Argo CD vs Flux
| Argo CD | Flux | |
|---|---|---|
| Project age | 2018 | 2018 (v2 since 2021) |
| UI | Yes — best-in-class | Minimal (Capacitor / Weave GitOps) |
| Multi-tenant model | Projects + AppProjects | Tenancy via separate Kustomizations |
| Composition | Application of applications | Kustomizations + HelmReleases |
| Declarative API | CRDs (Application) | CRDs (GitRepository, Kustomization, HelmRelease) |
| Pull or notification | Default poll, optional webhook | Default poll, optional notifications |
| Where it shines | UI-driven workflows, multi-cluster | Pure 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/prodanswers “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: HEADon a Helm repo means surprise upgrades. - Don’t
helm templateoutside 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.
| Stage | Tool | Output |
|---|---|---|
| Lint, test | GitHub Actions | Pass/fail |
| Build image | GitHub Actions | Image tag pushed |
| Update manifest | GitHub Actions | PR or commit to gitops repo with new tag |
| Reconcile | Argo CD | Cluster 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
- Platform Engineering and IDPs — GitOps is the deployment leg.
- Kubernetes for App Developers — the runtime layer.
- The Argo CD docs on ApplicationSets — pure gold.
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 .