CoursesFluxFlux vs Argo CD

Flux vs Argo CD

Choosing your GitOps tool.

Advanced10 min · lesson 12 of 12

Two locksmiths keep an office building matching its master blueprint. One works out of a toolbox: no shopfront, no reception desk, they read the plans and fix the locks directly. The other runs a staffed control room with a wall of monitors, a sign-in desk, and a board showing every door's status. Both keep the building true to the plan. They give you very different things to watch and to protect. Flux and Argo CD are those two locksmiths for Kubernetes (the open-source system that runs and coordinates your containers across a fleet of machines). Both are CNCF-graduated (Cloud Native Computing Foundation, the open-source body that stewards Kubernetes) GitOps engines. GitOps means you keep the desired state of your cluster in Git (the version-control system that records every change to a set of files), and a controller (a background program that watches the cluster and acts to fix it) reconciles the live cluster to match, correcting drift as it goes. Same job. Different shape. And for a defender, a different thing to secure.

Two shapes of the same job

Flux is a toolbox. It ships as a handful of small controllers (background programs that each watch one kind of object and reconcile it), and you compose them with CRDs (Custom Resource Definitions, a way to add your own object types to the Kubernetes API). There is source-controller, which fetches Git and OCI (Open Container Initiative, the open standard for packaging container images and related artifacts) sources; kustomize-controller, which renders and applies manifests (the YAML files that declare what should run); helm-controller, which installs Helm charts (Helm is a package manager for Kubernetes); and a couple more. There is no first-party Flux server and no built-in dashboard. You drive it from the CLI (command-line interface) and from Git, and Flux manages Flux the same way it manages everything else, as objects in Git.

Argo CD moves the control plane somewhere you can log into. It runs a stateful application controller plus an API (application programming interface) server with a rich web UI (user interface). The UI draws your live resource tree, shows a diff of desired state against what is actually running, and lets an operator sync or roll back with a click. The desired state is still Argo Application objects, usually stored in Git, but the server is the operational hub: the thing you harden, back up, and hand people accounts on. Flux leans Git-native and headless. Argo leans server-and-UI-centric. Neither is more 'real' GitOps. They put the control plane in different places.

Here is the same deployment expressed for each engine. Read them next to each other.

kustomization.yaml
# Flux: a Kustomization drives reconciliation, no server involved
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: podinfo
namespace: flux-system
spec:
interval: 10m
sourceRef:
kind: GitRepository
name: gitops
path: ./apps/podinfo
prune: true # delete resources removed from Git
application.yaml
# Argo CD: an Application, reconciled by the argocd controller
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: podinfo
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/org/gitops
targetRevision: main
path: apps/podinfo
destination:
server: https://kubernetes.default.svc
namespace: podinfo
syncPolicy:
automated:
prune: true # OFF by default, you must opt in
selfHeal: true # correct manual drift, also OFF by default
syncOptions:
- CreateNamespace=true

Look hard at the syncPolicy block in the Argo Application. Turning on automated does not turn on pruning or self-heal. Both prune and selfHeal default to false. So a resource you delete from Git quietly lingers in the cluster, and a manual kubectl edit (kubectl is the command-line tool for talking to Kubernetes) is left in place, because drift on its own never triggers a sync when selfHeal is off. That is the reverse of what most people mean by GitOps. Flux's Kustomization prunes the moment you set prune: true, and it re-applies the full desired state on every interval, so drift gets corrected without a second flag. The defaults train different instincts, and that difference bites teams during migration more than any feature gap does.

What you actually secure is different

For an operator, the sharpest difference shows up when you list the running pods. Ask each namespace (a named partition of the cluster) what it is running, and the security bill becomes obvious.

terminal
kubectl get pods -n flux-system
output
NAME READY STATUS RESTARTS AGE
helm-controller-5b9c8d7f6-x2p9q 1/1 Running 0 6d
kustomize-controller-6d8f7c9b4-lm3tz 1/1 Running 0 6d
notification-controller-7c9d8f6b5-nv4kx 1/1 Running 0 6d
source-controller-8f7d9c6b4-pq2wn 1/1 Running 0 6d
terminal
kubectl get pods -n argocd
output
NAME READY STATUS RESTARTS AGE
argocd-application-controller-0 1/1 Running 0 6d
argocd-applicationset-controller-6b7c9f8d4-2xk9p 1/1 Running 0 6d
argocd-dex-server-7f9c6d5b4-9wq2r 1/1 Running 0 6d
argocd-notifications-controller-5c8d7f6b9-lm4tz 1/1 Running 0 6d
argocd-redis-6fd9c88b5-nv8kx 1/1 Running 0 6d
argocd-repo-server-7d9f8c6b5-pq7wn 1/1 Running 0 6d
argocd-server-59d7c9f8b-tzk6x 1/1 Running 0 6d

Flux gives you four narrow controllers by default and nothing else. No login, no web endpoint, no session store. The trust boundary lives in two places you already run: the Git repository (protect it with reviews and signed commits) and Kubernetes RBAC (Role-Based Access Control, the cluster's own permission system), which scopes what each controller may apply.

Argo CD hands you a platform to defend. argocd-server exposes an API and a UI over the network, so anyone who can reach it gets a login prompt worth attacking. argocd-dex-server brokers SSO (Single Sign-On) to your identity provider. argocd-redis (Redis is an in-memory data store) caches the manifests the repo-server renders. Admin session tokens are signed with a key held in the argocd-secret object, so that secret and that Redis cache are inside your blast radius now. None of this is a mark against Argo. It buys you a place to see and govern GitOps across a fleet. But you own a server: put it behind SSO, fence it with a NetworkPolicy (a cluster firewall rule that limits which pods can reach it), back up argocd-secret, and watch the server logs for failed logins. Flux has less to secure because it has less surface to attack.

Scaling to many teams

Both tools answer fan-out and multi-tenancy, and each answers in character. Flux composes. One Kustomization waits on another through dependsOn, each tenant gets its own GitRepository plus a locked-down ServiceAccount (an identity for workloads inside the cluster), and reconciliation runs under that identity by impersonation. A tenant's GitOps can do exactly what their ServiceAccount's RBAC allows and nothing more, enforced by the same rules that govern the rest of the cluster.

Argo CD reaches for ApplicationSets. One controller-generated template fans a single spec across clusters, directories, or pull requests, backed by AppProjects that fence which repositories, destinations, and resource kinds a team may touch. If you want a self-service dashboard where a platform team hands developers a guarded slice of one shared Argo instance, that project-and-RBAC story is hard to beat. If you would rather every tenant be a plain Git directory reconciled by its own controller with no shared server to guard, Flux fits more naturally. The trade is centralized governance against a smaller blast radius.

applicationset.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: tenants
namespace: argocd
spec:
generators:
- git:
repoURL: https://github.com/org/gitops
revision: main
directories:
- path: tenants/* # one Application per tenant dir
template:
metadata:
name: '{{path.basename}}'
spec:
project: '{{path.basename}}' # matched by an AppProject of the same name
source:
repoURL: https://github.com/org/gitops
targetRevision: main
path: '{{path}}'
destination:
server: https://kubernetes.default.svc
namespace: '{{path.basename}}'
syncPolicy:
automated:
prune: true
selfHeal: true
What each tool puts on your plate
Flux (headless toolkit)
No server, no UI
nothing extra to expose or back up
Trust lives in Git + RBAC
repo reviews, scoped ServiceAccounts
Observe from the CLI
flux get, kubectl
Native image automation
writes new tags back to Git
Argo CD (server + UI)
API server + web UI
guard with SSO + NetworkPolicy
Argo RBAC + AppProjects
fence repos, dests, kinds
Central login + tokens
protect argocd-secret, Redis
ApplicationSets
fleet templating across clusters
Same reconcile loop, different attack surface. Flux gives you less to secure. Argo gives you a place to see and govern.

Driving each on day two

Operation diverges as much as architecture. With Flux you inspect and nudge entirely from the CLI, and there is no extra endpoint to expose or patch. You ask what it reconciles, and you tell it to reconcile now.

terminal
flux get kustomizations
flux reconcile kustomization podinfo --with-source
output
NAME REVISION SUSPENDED READY MESSAGE
flux-system main@sha1:a1b2c3d False True Applied revision: main@sha1:a1b2c3d
podinfo main@sha1:a1b2c3d False True Applied revision: main@sha1:a1b2c3d
► annotating GitRepository gitops in flux-system namespace
✔ GitRepository annotated
◎ waiting for GitRepository reconciliation
✔ fetched revision main@sha1:9f4c2e1
► annotating Kustomization podinfo in flux-system namespace
✔ Kustomization annotated
◎ waiting for Kustomization reconciliation
✔ applied revision main@sha1:9f4c2e1

With Argo you mostly work through the UI, but the argocd CLI mirrors it faithfully for scripting and CI (Continuous Integration) pipelines. You log in to the API server first, which is itself a standing reminder that there is a server to reach and therefore a server to protect.

terminal
argocd login argocd.example.com --sso
argocd app create podinfo \
--repo https://github.com/org/gitops \
--path apps/podinfo \
--dest-server https://kubernetes.default.svc \
--dest-namespace podinfo
argocd app sync podinfo
argocd app get podinfo
output
Name: argocd/podinfo
Project: default
Server: https://kubernetes.default.svc
Namespace: podinfo
URL: https://argocd.example.com/applications/podinfo
Source:
- Repo: https://github.com/org/gitops
Target: main
Path: apps/podinfo
Sync Policy: <none>
Sync Status: Synced to main (9f4c2e1)
Health Status: Healthy
GROUP KIND NAMESPACE NAME STATUS HEALTH MESSAGE
Service podinfo podinfo Synced Healthy service/podinfo created
apps Deployment podinfo podinfo Synced Healthy deployment.apps/podinfo created

A common and defensible setup runs both. Argo CD as the human-facing dashboard, Flux for unattended flows like image automation. Flux ships image-reflector-controller and image-automation-controller, which scan a registry for new tags and write the chosen tag back to Git as a commit, so the promotion lands in your history where you can audit it. Argo CD has no built-in equivalent (you add the separate Argo CD Image Updater project or wire it through CI). Whichever you run, the desired state stays in Git. The tool is the reconciler, and in Argo's case, the window you watch it through.

Auto-sync is not auto-prune, and never double-own a namespace
Two traps catch teams here. First, on Argo CD, enabling syncPolicy.automated does not enable prune or selfHeal; both default to false, so deleted manifests linger and a manual kubectl edit is not corrected until you turn selfHeal on, because drift by itself never triggers a sync. Set both flags explicitly if you want Flux-like delete-and-correct behavior. Second, never point Flux and Argo CD at the same namespace. Two controllers each convinced they own the objects will fight over prune and thrash your workloads in an endless apply-delete loop, which also lights up your alerting and buries real events in the noise. Split ownership cleanly: give each tool its own namespaces and never overlap.
Quick check
01Your Argo CD Application sets syncPolicy.automated with nothing underneath it. No new commit lands in Git, but an operator changes the live Deployment's replica count with kubectl edit. What happens?
Correct — Argo notices the difference and reports it, but with selfHeal off it takes no action. Drift on its own is not a sync trigger.
Incorrect — That is what selfHeal buys you, and you only get it by writing selfHeal: true under automated yourself. It ships off.
Incorrect — Prune removes objects Git no longer declares, and it is off here too. Neither flag rebuilds a workload over one edited field.
Incorrect — Nothing about drift locks the app. Your next commit, or a sync you run by hand, still goes through as normal.
02You install both tools in throwaway namespaces and run kubectl get pods -n flux-system and kubectl get pods -n argocd. Reading the two lists, what does the Argo CD install add that you now have to defend?
Incorrect — Both engines add their own object types through CRDs, and a type definition is not something an attacker reaches over the network.
Incorrect — The install ships no such rule. Fencing argocd-server with a NetworkPolicy is work you take on yourself after it is running.
Correct — argocd-server answers logins, argocd-secret holds the key those session tokens are signed with, and argocd-redis stores rendered manifests. All three are yours to guard now.
Incorrect — Those two controllers belong to Flux. Argo CD ships no equivalent, so they never show up in the argocd pod list.
03Halfway through a migration, one namespace's Deployment is claimed by a Flux Kustomization with prune: true and by an Argo CD Application at the same time. What should you expect?
Incorrect — Neither one steps back. Both keep reconciling on their own interval, and both hold the rights to write that object.
Incorrect — RBAC scopes what a controller may touch, not who owns an object. Both hold the rights they need, so both keep applying.
Incorrect — Matching content does not settle ownership. Prune is where it breaks: each side sees the other's object as one Git no longer declares.
Correct — That loop runs on every reconcile, and the alert storm it throws off buries the events you actually needed to see.

Before you commit to either tool, do one concrete thing: install it in a throwaway namespace, run kubectl get pods, and write down every endpoint, secret, and account the install creates. That inventory is your hardening checklist, and it is the honest measure of what each option costs you to run safely.

Try this

Run kubectl get pods -n flux-system on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: auto-sync is not auto-prune, and never double-own a namespace. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related