What is GitOps and how do Argo CD and Flux reconcile cluster state?
GitOps flips deployment from push to pull. Instead of a CI job running kubectl apply or helm upgrade against the cluster, a controller running in or near the cluster continuously compares live resources with a declarative source and converges them. Both Argo CD and Flux reconcile from Git. Flux also treats OCI artifacts, Helm repositories and S3-compatible buckets as first-class source types. Each tool detects drift and can optionally self-heal (reapply the desired state) and prune (delete resources that were removed from the source).
Operationally, this differs from pipeline-driven delivery in three ways:
- Credentials: A push pipeline stores cluster credentials in CI. With GitOps, CI only needs write access to a repository, and the controller holds cluster access itself.
- Live edits: If someone runs
kubectl edit, a pipeline doesn't notice until the next deploy overwrites the change, or fails to. A GitOps controller flags the drift and, with self-heal on, reverts it. - Failure signals: A pipeline fails as a red job. In GitOps, the merge succeeds and any failure appears later as controller status and events. Alerting has to watch the controller, not CI.
Teams adopt GitOps for three reasons: Git history becomes the audit trail, environments become reproducible from a commit, and bespoke deploy scripts mostly disappear. Because Argo CD and Flux implement the same model, the choice comes down to who operates the tooling and how application teams consume it.
One incident caveat: self-heal and pruning will revert a manual emergency fix within one reconcile cycle. Define a policy for suspending reconciliation (Flux) or disabling auto-sync (Argo CD) during incidents. Write down who may suspend reconciliation and how it gets re-enabled.
How do Argo CD and Flux differ in architecture?
Argo CD is an application-centric platform. Each deployable unit is an Application custom resource that points at a repo path, revision and destination cluster. The API server backs the web UI and CLI. The repo server clones Git and renders manifests from plain YAML, Kustomize or Helm. The application controller diffs the rendered output against live state and syncs it. Teams usually run one central instance and let ApplicationSets generate an Application per cluster or per service.
Flux is a toolkit of separate controllers. The source controller fetches GitRepository and chart sources. The kustomize and helm controllers apply Kustomization and HelmRelease objects. The notification controller handles alerts and inbound webhooks. Image automation controllers are optional. Flux typically runs once per cluster and is bootstrapped from that cluster's path in a shared repo.
Central Argo CD Per-cluster Flux
┌──────────────┐ ┌─────────── Git repo ───────────┐
│ Argo CD │── creds ──► prod │ clusters/prod clusters/stg │
│ (hub cluster)│── creds ──► staging └──────▲───────────────▲─────────┘
│ UI/API │── creds ──► dev pull│ pull│
└──────▲───────┘ ┌─────┴────┐ ┌──────┴───┐
│ pull │ prod: │ │ staging: │
Git repo │ Flux │ │ Flux │
└──────────┘ └──────────┘
| Concept | Argo CD | Flux |
|---|---|---|
| Deployable unit | Application |
Kustomization / HelmRelease |
| Generating many units | ApplicationSet |
Kustomize overlays, one path per cluster |
| Tenant boundary | AppProject |
Namespaces plus service accounts set on each Kustomization or HelmRelease |
These designs lead to different trade-offs:
- Control and credentials. Central Argo CD gives you one place to see and control every cluster. It also stores credentials for all of them, which makes it a high-value target. Flux keeps each cluster's credentials local, but you get no single pane of glass.
- Attack surface and debugging. The Argo CD UI adds API surface. You have to put SSO in front of it, harden its RBAC and keep it upgraded. Flux exposes less, but you debug it through
kubectland resource status conditions, which takes longer to learn. - Scaling. Argo CD feels pressure from manifest rendering in the repo server and from repo polling. Flux scales through controller concurrency settings and fetch intervals. In both tools, Git webhooks cut polling. Argo CD can also shard its application controller across clusters.
CENTRAL ARGO CD (hub) PER-CLUSTER FLUX (one per cluster)
┌──────────────────── Git repo (shared) ────────────────────┐
│ apps/ clusters/prod/ clusters/staging/ │
└─────▲────────────────────────▲────────────────────▲───────┘
│ clone + render │ pull │ pull
┌──────────┴───────────┐ ┌────────┴─────────┐ ┌────────┴─────────┐
│ hub cluster │ │ prod cluster │ │ staging cluster │
│ ┌────────▼─────────┐ │ │ source-controller│ │ source-controller│
│ │repo server: │ │ │ │ artifact│ │ │ artifact│
│ │renders YAML, │ │ │ ▼ │ │ ▼ │
│ │Kustomize, Helm │ │ │ kustomize-ctrl │ │ kustomize-ctrl │
│ └────────┬─────────┘ │ │ helm-ctrl │ │ helm-ctrl │
│ ┌────────▼─────────┐ │ │ │ apply │ │ │ apply │
│ │app controller: │ │ │ ▼ │ │ ▼ │
│ │diff live, sync │ │ │ workloads │ │ workloads │
│ └────────┬─────────┘ │ │ notification-ctrl│ │ notification-ctrl│
│ ┌──────┐ │ │ │ (alerts,webhooks)│ │ (alerts,webhooks)│
│ │API + │ │ kube creds│ │ image automation │ │ image automation │
│ │web UI│ │ │ │ (optional) │ │ (optional) │
│ └──────┘ │ │ │ │ │ │
└──────────┼───────────┘ └──────────────────┘ └──────────────────┘
┌────────┼────────┐ Flux: each cluster pulls its own path;
▼ ▼ ▼ no hub holding every cluster's creds
┌─────┐ ┌─────┐ ┌─────┐
│prod │ │ stg │ │ dev │ Argo CD: hub syncs spokes with stored
└─────┘ └─────┘ └─────┘ creds (single point of control)
A central Argo CD instance renders manifests and syncs several spoke clusters using stored credentials, while Flux runs its own controller set in each cluster and pulls that cluster's path from the same Git repo.
How does each tool handle Helm releases, and why does it matter for backend charts?
Argo CD treats a chart as a manifest generator. It runs the equivalent of helm template, then diffs and applies the rendered objects itself. Because Helm never installs anything, there is no release record in the cluster. helm list shows nothing, and helm rollback has no history to work from, so you roll back by reverting Git or syncing an earlier revision. Argo CD maps Helm hooks onto its own sync hooks, for example pre-upgrade becomes PreSync. The mapping is close but not exact, so hook ordering and deletion policies need a second look. Templates that call lookup, or that depend on .Release.IsUpgrade, can render differently than they would under plain Helm. lookup is the common case: charts often use it to reuse an existing generated password, and under Argo CD it typically returns nothing, so the password can change on every sync.
Flux's helm-controller runs real Helm installs and upgrades. Release history, helm rollback and native hooks all behave as they do under the Helm CLI. The cost is Helm's failure modes. A failed or interrupted upgrade can leave a release failed or stuck in a pending state. Flux handles this through remediation settings, which retry and then roll back or uninstall. When those don't resolve it, you intervene manually.
Questions to ask about your charts
- Do any templates use
lookupor branch on install versus upgrade? - Do migrations or other critical steps run as Helm hooks?
- Do on-call runbooks rely on
helm rollbackorhelm history? - Do third-party charts you consume assume a real Helm release?
# Flux: helm-controller performs a real Helm install/upgrade (release history exists)
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: orders-api
namespace: orders
spec:
interval: 10m
chart:
spec:
chart: orders-api
version: "1.4.2"
sourceRef:
kind: HelmRepository
name: internal-charts # assumes a HelmRepository with this name exists
install:
remediation:
retries: 3 # retry a failed first install; uninstall after retries run out
upgrade:
remediation:
retries: 3 # retry a failed upgrade before remediating
strategy: rollback # roll back to the last successful Helm revision
values:
replicaCount: 3
---
# Argo CD: the chart is rendered like `helm template` and applied by Argo CD (no Helm release record)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: orders-api
namespace: argocd
spec:
project: default
source:
repoURL: # placeholder Helm repository
chart: orders-api
targetRevision: 1.4.2
helm:
valuesObject:
replicaCount: 3
destination:
server:
namespace: orders
The same backend chart delivered two ways: a Flux HelmRelease with install and upgrade remediation that runs real Helm operations, and an Argo CD Application that renders the chart with inline values and applies the output itself.
How do you implement multi-environment delivery and promotion with Argo CD or Flux?
A layout that works with either tool keeps a shared base/ per service, overlays/dev, overlays/staging and overlays/prod for replicas, resource limits and image tags, and a clusters/<name>/ directory that serves as each cluster's entry point.
Argo CD consumes this with an ApplicationSet. A Git directory generator creates one Application per overlay path. A cluster generator targets every registered cluster whose labels match, such as env: staging, and templates the overlay path from those labels.
Flux consumes it through bootstrap. Each cluster syncs its own clusters/<name>/ path, which holds Kustomization resources pointing at the matching overlays.
Promotion
A new version moves forward when someone commits its tag to the next overlay. Automation for this step varies:
- Flux: the image reflector and image automation controllers watch registries and commit new tags back to Git.
- Argo CD: the Image Updater, a separately maintained project, does similar work.
Both handle "deploy the newest image to dev" well. Neither handles "promote to prod after staging passes its checks."
That gating logic usually ends up in CI or a dedicated promotion tool, whichever GitOps engine you choose. Bot commits have costs:
- They conflict with branch protection rules.
- They create merge conflicts when humans edit the same overlay.
- They add noise to history.
A CI job that opens a reviewed pull request per promotion gives a cleaner audit trail.
Keep overlays as plain Kustomize or Helm values. Application/ApplicationSet and Kustomization/HelmRelease do not map one-to-one, so if your manifests are portable, a later migration only requires rewriting the thin wiring layer.
# Argo CD: one ApplicationSet fans out to every cluster labelled with an env.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: orders-api
namespace: argocd
spec:
generators:
- clusters:
selector:
matchExpressions:
- {key: env, operator: In, values: [dev, staging, prod]}
template:
metadata:
name: 'orders-api-{{name}}'
spec:
project: default
source:
repoURL:
targetRevision: main
path: 'apps/orders-api/overlays/{{metadata.labels.env}}' # env label picks the overlay
destination:
server: '{{server}}'
namespace: orders
syncPolicy:
automated: {}
---
# Flux: committed at clusters/staging-eu1/orders-api.yaml; bootstrap syncs this directory.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: orders-api
namespace: flux-system
spec:
interval: 5m
path: ./apps/orders-api/overlays/staging # same plain Kustomize overlay Argo CD uses
prune: true
targetNamespace: orders
sourceRef:
kind: GitRepository
name: flux-system
An Argo CD ApplicationSet and a Flux Kustomization both point at the same plain Kustomize overlay. Only this thin wiring layer would need rewriting in a migration.
How do you run a database migration before a rollout with sync waves or dependsOn?
In Argo CD, the usual pattern is to annotate the migration Job with argocd.argoproj.io/hook: PreSync. Argo CD runs it before applying the rest of the Application and starts the sync only if the Job succeeds. A hook delete policy such as BeforeHookCreation keeps the next run from colliding with the old Job. Sync waves (argocd.argoproj.io/sync-wave) give you finer ordering inside a single sync: put the migration in an earlier wave than the Deployment, and Argo CD waits for each wave to become healthy before moving on.
Flux does not have per-resource hooks in Kustomizations. You split the work into two Kustomizations instead: one contains only the migration Job, with wait enabled or explicit health checks, and the app Kustomization declares dependsOn pointing at it. Jobs are immutable, so give each run a unique name or enable force so Flux can recreate the Job. If the service ships as a chart, helm-controller runs native Helm pre-upgrade hooks, which keeps the ordering inside the chart.
Neither tool makes the migration safe. Old pods keep serving traffic during the rollout, so the schema must work for both versions. Large backfills don't belong in a sync-blocking Job. Reverting a Git commit rolls back manifests, not the schema. Use expand-and-contract migrations and write down a roll-forward policy: when a release fails, you fix it with a new commit rather than a revert.
# Flux: migration and app live in separate Kustomizations.
# orders-app is not reconciled until orders-db-migrate is Ready.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: orders-db-migrate
namespace: flux-system
spec:
interval: 10m
path: ./deploy/orders/migrate # contains only the migration Job
prune: true
sourceRef:
kind: GitRepository
name: platform
wait: true # Ready only after the Job completes; a failed Job fails this Kustomization
force: true # Job spec is immutable: recreate it when the image tag changes
timeout: 15m # keep migrations short; long backfills belong elsewhere
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: orders-app
namespace: flux-system
spec:
dependsOn:
- name: orders-db-migrate # gate the rollout on a successful migration
interval: 10m
path: ./deploy/orders/app # Deployment, Service, etc.
prune: true
sourceRef:
kind: GitRepository
name: platform
wait: true
timeout: 10m
# Reverting the app commit later does not undo the schema change;
# the migration must stay compatible with the old and new app versions.
Two Flux Kustomizations linked by dependsOn, so the Deployment rolls out only after the migration Job has completed successfully.
Who should get access: RBAC, multi-tenancy and secrets in Argo CD vs Flux
Argo CD runs its own authorization layer on top of Kubernetes RBAC. Users sign in to the UI and API through SSO, and Argo CD policies map their groups to actions on applications. AppProjects set the boundaries: which repositories a team can deploy from, which clusters and namespaces it can target, and which resource kinds it can create. Flux has no UI or user-facing API to protect. Access is governed by Git permissions and plain Kubernetes RBAC, and each Kustomization or HelmRelease can name a service account that the controller impersonates. A tenant's manifests can do only what that account is allowed to do.
| Concern | Argo CD | Flux |
|---|---|---|
| Who can trigger or sync | Argo CD RBAC policies mapped to SSO groups | Git write access plus Kubernetes RBAC |
| Tenant boundary | AppProject restrictions on sources, destinations and kinds | Namespaced service account impersonation |
| SOPS decryption | Needs a plugin or a custom repo-server setup | Built into kustomize-controller |
Argo CD suits teams where backend developers self-serve, because they get a UI for syncs, rollbacks and logs without needing cluster credentials. If your organization has invested heavily in Kubernetes RBAC and treats Git as the only control plane, Flux adds no second policy model for you to audit.
Never commit plaintext secrets. Two patterns work with either tool. One is SOPS-encrypted manifests. The other is an external secrets operator that syncs values from a secret store, so Git holds only references. With Argo CD, be careful with SOPS: the repo server renders manifests and caches the output. If decryption happens during rendering, plaintext values can end up in that cache and in diffs. That makes the external operator pattern the safer default for Argo CD.
What does an on-call engineer do when a deploy is stuck?
Both tools tell you the same thing: which resource diverged from Git, and why. They show it in different places. Argo CD puts it in a UI. Flux puts it in the Kubernetes API, so you read it with the CLI or kubectl.
Argo CD runbook
- Find the failing resource. Run
argocd app get <app>or open the resource tree. Sync status (Synced/OutOfSync) and health (Healthy/Degraded/Progressing) are reported separately. An app can be Synced and still Degraded, for example when a Deployment applied cleanly but its pods crash-loop. - Read the error. Open the diff view and the failing node's events, or run
kubectl describeon the resource itself. The diff tells you whether Argo CD and the cluster disagree on the spec or the spec is fine and the workload is unhealthy. - Force a reconcile with
argocd app sync <app>. - Emergency change. Turn off automated sync first. If self-heal is still on, Argo CD reverts your manual edit within one reconcile loop.
Flux runbook
- Find the failing resource. Run
flux get kustomizations -Aandflux get helmreleases -Aand look for Ready=False. - Read the error. Run
kubectl describeon the custom resource. Its status conditions carry the controller's message. Usekubectl get eventsfor what happened inside the namespace. If the notification controller is configured, the alert may already be in your chat channel. - Force a reconcile with
flux reconcile kustomization <name> --with-source. This fetches Git first instead of reapplying a stale artifact. - Emergency change. Run
flux suspendbefore you edit anything.
Re-enabling reconciliation, either tool
Commit the manual change to Git first. Then run flux resume or re-enable auto-sync. If you resume before Git matches the cluster, the controller treats your fix as drift and rolls it back.
Argo CD or Flux: which should your team choose, and when should you avoid both?
Score your team against these criteria before choosing:
- Platform staffing. Argo CD is a central service you must run: someone owns its upgrades, SSO integration and RBAC policy. Flux has less to operate, but you build visibility yourself.
- Developer UI. If developers need to see sync status and diffs without cluster access, Argo CD's UI provides that out of the box. With Flux you add a dashboard or wire status into alerts.
- Helm semantics. Argo CD renders charts with
helm template, solookupdoes not see live cluster state andhelm historyshows nothing. Charts that depend on either fit Flux's helm-controller better, because it creates real Helm releases. - Cluster count and topology. A single Argo CD instance managing many clusters gives you one view, but it holds credentials for every cluster you harden. Flux runs per cluster, which limits the blast radius, and you aggregate status separately.
- Existing access control. Flux reuses Kubernetes RBAC. Argo CD adds its own RBAC layer on top of your identity provider.
Either tool pays for itself by replacing bespoke deploy scripts and by catching drift before it turns into an incident.
Skip GitOps controllers in these cases:
- You have one cluster and a CI job that runs
helm upgrade, and that job already works. - Your deployment targets are not Kubernetes.
- Your releases are mostly imperative, stateful operations.
For these, use CI-driven kubectl apply or helm upgrade. Larger organisations sometimes run both tools: Flux for platform tiers and Argo CD for application teams.
| Criterion | Argo CD | Flux |
|---|---|---|
| UI | Built in | Add-on |
Helm releases, lookup and hooks |
Rendered; hooks mapped to Argo CD hooks | Native Helm SDK |
| Control plane | Central, multi-cluster | Per cluster |
| RBAC | Own layer plus SSO | Kubernetes RBAC |
| Operating cost | Higher; you run a service | Lower; you assemble visibility yourself |
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to argo cd vs flux, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.