GitOps is a deployment model in which the desired state of every environment lives in Git, and an agent inside the cluster pulls that state and keeps reconciling the cluster to match it. CI builds and tests the image; a reviewed commit to a deployment repository, not a pipeline running kubectl, is what ships it. On Kubernetes the two mature GitOps solutions are Argo CD and Flux, both CNCF graduated projects. Choose between them on operating model and cluster count, then spend most of your effort on repository layout, promotion, secrets and failure handling, because that is where GitOps setups break.
Key takeaways
- GitOps is a pull model. The cluster fetches its desired state, so CI never holds cluster credentials, and drift is corrected instead of accumulating.
- Keep application code and deployment config in separate repositories, use a directory per environment (not a branch per environment), and promote by pull request.
- Argo CD fits teams that want a UI, central multi-cluster management and Argo Rollouts. Flux fits teams that want Kubernetes-native RBAC, real Helm releases, built-in SOPS decryption and image automation.
- Canary and blue-green are layered on top with Argo Rollouts or Flagger. Plain GitOps gives you rolling updates and
git revert, not traffic shifting. - Keep secrets out of Git with External Secrets Operator, or encrypt them with SOPS. Back up the decryption keys.
- Most GitOps incidents come from self-heal fighting another controller, prune deleting something stateful, or an emergency
kubectlfix that gets silently reverted.
How GitOps deployment works
The OpenGitOps project, part of the CNCF, defines four principles. The system is described declaratively. That description is versioned and immutable, typically in Git. Software agents pull it automatically. Those agents continuously reconcile the running system toward it.
The last two principles are what separate GitOps from "CI runs kubectl apply". In a push pipeline the CI system holds credentials for every cluster and applies changes once. If someone edits a Deployment by hand an hour later, nothing notices. In a pull model a controller inside the cluster compares desired state with live state on every reconcile loop and fixes the difference.
| Push (CI applies) | Pull (GitOps controller) | |
|---|---|---|
| Who holds cluster credentials | CI system, for every cluster | A controller inside each cluster |
| Drift from manual changes | Unnoticed until the next deploy | Detected, then reverted or flagged |
| Audit trail | CI logs, if retained | Git history plus controller events |
| Rollback | Re-run an old pipeline | git revert |
| Cluster on a private network | Needs inbound access from CI | Needs only outbound access to Git and the registry |
| CI outage | Blocks all deploys | Blocks new images; the cluster keeps converging |
The full loop for one service:
CI's job ends when it has produced an immutable image and proposed a change to the deployment repository. Everything after the merge belongs to the controller. That split is the point: CI needs push access to a registry and write access to one repository, never to a cluster. Our GitHub Actions pipeline guide covers the CI half in detail.
Repository layout that survives growth
Two decisions matter more than which controller you pick.
Separate the application repo from the deploy repo. CI commits image updates to the deploy repo. If that were the app repo, every image bump would trigger another build. Separate repos also let you put stricter review rules (CODEOWNERS, required approvals) on production config than on application code.
Use a directory per environment, not a branch per environment. Branch-per-environment looks tidy, then turns every promotion into a merge between long-lived branches that have drifted apart. Directories with Kustomize overlays keep every environment visible in one tree, and a promotion becomes a small diff anyone can read.
deploy/
apps/
checkout/
base/
deployment.yaml
service.yaml
kustomization.yaml
overlays/
staging/
kustomization.yaml # image digest, replicas, env config
production/
kustomization.yaml
infrastructure/
controllers/ # ingress, cert-manager, external-secrets
configs/ # ClusterIssuers, ClusterSecretStores
clusters/
staging/ # entry point the staging cluster syncs
production/Each overlay pins the image by digest, so production runs the exact bytes that passed staging. This is apps/checkout/overlays/production/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: checkout-production
resources:
- ../../base
images:
- name: ghcr.io/acme/checkout
digest: sha256:REPLACE_WITH_DIGEST_FROM_STAGING
patches:
- path: resources.yamlPromotion is then a one-line change, made by a CI job or a person, and reviewed like any other pull request:
cd deploy/apps/checkout/overlays/production
kustomize edit set image ghcr.io/acme/checkout@sha256:REPLACE_WITH_DIGEST_FROM_STAGING
git switch -c promote/checkout
git commit -am "checkout: promote staging digest to production"
git push -u origin HEADArgo CD: a working setup
Argo CD runs as a set of controllers plus an API server and a web UI. The install uses server-side apply because some Argo CD CRDs exceed the annotation size limit of client-side kubectl apply:
kubectl create namespace argocd
kubectl apply -n argocd --server-side --force-conflicts \
-f https://raw.githubusercontent.com/argoproj/argo-cd/v3.5.3/manifests/install.yaml
argocd admin initial-password -n argocdPin the version in the URL (3.5.3 was the latest release in early October 2026) instead of tracking stable. For production, use the HA manifests or the Helm chart, connect SSO, disable the local admin account, and manage Argo CD's own configuration from Git once it is running.
Fence each team in with an AppProject
An AppProject decides which repositories a team may deploy from, which clusters and namespaces it may deploy to, and which resource kinds it may create. The built-in default project allows everything, so keep real workloads out of it.
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: checkout
namespace: argocd
spec:
description: Checkout team services
sourceRepos:
- https://github.com/acme/deploy.git
destinations:
- server: https://kubernetes.default.svc
namespace: checkout-*
clusterResourceWhitelist:
- group: ""
kind: Namespace
namespaceResourceBlacklist:
- group: ""
kind: ResourceQuota
- group: networking.k8s.io
kind: NetworkPolicyThe checkout team can create namespaces that match checkout-* and any namespaced resource except quotas and network policies, which stay with the platform team.
An Application with automated sync
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: checkout-production
namespace: argocd
spec:
project: checkout
source:
repoURL: https://github.com/acme/deploy.git
targetRevision: main
path: apps/checkout/overlays/production
destination:
server: https://kubernetes.default.svc
namespace: checkout-production
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
- PruneLast=true
retry:
limit: 5
backoff:
duration: 10s
factor: 2
maxDuration: 3mprune deletes resources that were removed from Git. selfHeal reverts manual changes. ServerSideApply sidesteps the annotation size limit and handles fields owned by other controllers more gracefully. Argo CD polls Git every three minutes by default, so add a webhook from your Git host to start syncs within seconds of a merge.
If you are upgrading from 2.x, read the 2.14 to 3.0 upgrade notes first. Argo CD 3.0 switched resource tracking from a label to an annotation, and RBAC update and delete grants on an Application no longer extend to the resources it manages.
Stamp out environments with an ApplicationSet
One hand-written Application per service per environment does not scale. An ApplicationSet generates them. This one creates an Application for every overlay directory of the checkout service:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: checkout
namespace: argocd
spec:
goTemplate: true
goTemplateOptions: ["missingkey=error"]
generators:
- git:
repoURL: https://github.com/acme/deploy.git
revision: main
directories:
- path: apps/checkout/overlays/*
template:
metadata:
name: "checkout-{{.path.basename}}"
spec:
project: checkout
source:
repoURL: https://github.com/acme/deploy.git
targetRevision: main
path: "{{.path.path}}"
destination:
server: https://kubernetes.default.svc
namespace: "checkout-{{.path.basename}}"
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=trueWhen environments live on separate clusters, swap the git generator for a clusters generator, or combine both in a matrix generator.
Order things with sync waves and hooks
Database migrations, CRDs and namespaces often need to land before workloads. Argo CD applies resources in waves, lowest number first, and supports hooks around the sync:
apiVersion: batch/v1
kind: Job
metadata:
name: checkout-migrate
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
backoffLimit: 1
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: ghcr.io/acme/checkout-migrations@sha256:REPLACE_WITH_DIGEST
args: ["migrate", "up"]If the migration fails, the sync stops and the old pods keep serving. Write migrations the previous release can survive (expand first, contract in a later release), because reverting the commit will not reverse the schema.
Flux: a working setup
Flux is a set of independent controllers. The source-controller fetches Git, OCI and Helm sources. The kustomize-controller and helm-controller apply them. The notification-controller handles webhooks and alerts. The image controllers handle image automation. There is no central server: each cluster runs its own Flux and reconciles its own path in the repository.
Bootstrap commits Flux's own manifests to the repository and installs them, so Flux manages its own upgrades from then on:
export GITHUB_TOKEN=REPLACE_WITH_TOKEN # used to create the deploy key
flux bootstrap github \
--owner=acme \
--repository=deploy \
--branch=main \
--path=clusters/production \
--components-extra=image-reflector-controller,image-automation-controller \
--read-write-keyThe Flux Operator from ControlPlane is an alternative installer: you declare a FluxInstance resource and the operator installs and upgrades Flux. It also ships a web UI, which matters if "Flux has no UI" was your reason to rule it out.
A source and a Kustomization
Bootstrap already created a GitRepository named flux-system for the fleet repository. Application config in a second repository gets its own source, and a Kustomization applies a path from it:
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: checkout-deploy
namespace: flux-system
spec:
interval: 1m
url: ssh://git@github.com/acme/checkout-deploy
ref:
branch: main
secretRef:
name: checkout-deploy-key
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: checkout
namespace: flux-system
spec:
interval: 10m
retryInterval: 1m
timeout: 5m
sourceRef:
kind: GitRepository
name: checkout-deploy
path: ./overlays/production
prune: true
wait: true
dependsOn:
- name: infra-configs
decryption:
provider: sops
secretRef:
name: sops-agewait: true makes the Kustomization report ready only when every resource it applied is healthy, which gives dependsOn real meaning: workloads wait for the controllers and CRDs they need. The decryption block lets Flux decrypt SOPS-encrypted secrets at apply time, something Argo CD needs a plugin for.
Image automation
Flux can watch a registry and commit new image references back to Git. The image APIs went GA (v1) in Flux 2.7.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: checkout
namespace: flux-system
spec:
image: ghcr.io/acme/checkout
interval: 5m
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: checkout
namespace: flux-system
spec:
imageRepositoryRef:
name: checkout
policy:
semver:
range: ">=1.4.0 <2.0.0"
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageUpdateAutomation
metadata:
name: staging-images
namespace: flux-system
spec:
interval: 10m
sourceRef:
kind: GitRepository
name: flux-system
git:
checkout:
ref:
branch: main
commit:
author:
name: fluxcdbot
email: fluxcdbot@users.noreply.github.com
messageTemplate: "staging: update images"
push:
branch: main
update:
path: ./apps/checkout/overlays/staging
strategy: SettersA marker comment in the overlay tells Flux which field to rewrite:
images:
- name: ghcr.io/acme/checkout
newTag: 1.4.2 # {"$imagepolicy": "flux-system:checkout:tag"}Let automation write to staging only. Production should move through a reviewed pull request, or through automation that pushes to a separate branch (push.branch: image-updates) from which you open one.
Argo CD vs Flux
| Argo CD | Flux | |
|---|---|---|
| Architecture | Central server: API, UI, repo server, application controller | Independent controllers in each cluster, no server |
| UI | Full web UI with diff, sync and history | Flux Operator web UI; most teams work from Git and the CLI |
| Multi-cluster | One instance manages many clusters (hub and spoke) | Usually one Flux per cluster, each pulling its own path |
| Access control | Own RBAC model plus SSO, scoped by AppProject | Kubernetes RBAC; a Kustomization can impersonate a service account |
| Helm | Renders charts to manifests; helm list shows nothing | Real Helm installs and upgrades with release history |
| Secrets in Git | Through plugins or External Secrets Operator | Native SOPS decryption |
| Image automation | Argo CD Image Updater, a separate project | Built in, v1 APIs |
| Progressive delivery | Argo Rollouts, which replaces the Deployment with a Rollout | Flagger, which wraps an existing Deployment |
| Blast radius if compromised | The hub holds credentials for every cluster it manages | Each cluster's Flux holds only its own access |
| Project status | CNCF graduated | CNCF graduated |
Choose Argo CD when many teams need to see and sync their apps without kubectl access, when one platform team manages many clusters from one place, or when you want Argo Rollouts and its analysis built into the same UI.
Choose Flux when each cluster should be self-sufficient, when you rely on Helm charts whose hooks and release history must behave like real Helm, when SOPS in Git is your secrets model, or when you want image automation without adding another project.
Either is fine for one cluster and a handful of services. Pick the one your team will actually read the documentation for, and never point both at the same resources.
Deployment strategies on top of GitOps
GitOps decides what should run. It does not decide how traffic moves from the old version to the new one. That is the job of the workload controller.
| Strategy | Mechanism | What the Git commit does | Rollback | Needs |
|---|---|---|---|---|
| Rolling update | Deployment replaces pods gradually | Changes the image digest | git revert | Honest readiness probes |
| Blue-green | Two ReplicaSets; a Service switches between them | Changes the digest; promotion flips the active Service | Flip back instantly | Argo Rollouts or Flagger, double capacity during the switch |
| Canary | A share of traffic or pods runs the new version; analysis gates each step | Changes the digest; steps run automatically | Automatic abort on failed analysis | Argo Rollouts or Flagger, metrics, ideally a mesh or ingress for exact weights |
Rolling updates need two settings that teams often skip: maxUnavailable: 0, so capacity never drops during a rollout, and a readiness probe that checks what the pod actually needs to serve. Without a meaningful readiness probe, a rolling update replaces healthy pods with broken ones at whatever speed you configured.
A canary with Argo Rollouts and automated analysis
This Rollout moves through three weights and runs a Prometheus analysis in the background from the first pause onward. It assumes two Services, checkout-stable and checkout-canary, both selecting app: checkout (Argo Rollouts adds a pod-template hash to each selector), and an application metric http_requests_total with a code label.
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: checkout
namespace: checkout-production
spec:
replicas: 10
revisionHistoryLimit: 3
selector:
matchLabels:
app: checkout
template:
metadata:
labels:
app: checkout
spec:
containers:
- name: checkout
image: ghcr.io/acme/checkout@sha256:REPLACE_WITH_DIGEST
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /ready
port: http
periodSeconds: 5
strategy:
canary:
stableService: checkout-stable
canaryService: checkout-canary
analysis:
templates:
- templateName: success-rate
startingStep: 1
args:
- name: service-name
value: checkout-canary
steps:
- setWeight: 10
- pause: { duration: 5m }
- setWeight: 30
- pause: { duration: 5m }
- setWeight: 60
- pause: { duration: 10m }
---
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: success-rate
namespace: checkout-production
spec:
args:
- name: service-name
metrics:
- name: success-rate
interval: 1m
failureLimit: 2
successCondition: result[0] >= 0.99
provider:
prometheus:
address: http://prometheus-operated.monitoring.svc:9090
query: |
sum(rate(http_requests_total{service="{{args.service-name}}",code!~"5.."}[2m]))
/
sum(rate(http_requests_total{service="{{args.service-name}}"}[2m]))If the success rate drops below the threshold more than twice, the rollout aborts and the canary scales down while the stable version keeps serving. Without a traffic router, Argo Rollouts approximates weights with pod counts. With Istio, an NGINX ingress or Gateway API configured under trafficRouting, the weights become exact request percentages. Our Istio service mesh guide covers the mesh side.
Flagger takes a different approach. You keep your Deployment, and a Canary resource tells Flagger to create primary and canary copies and shift traffic through your mesh or ingress. Flagger comes from the Flux project and Argo Rollouts from the Argo project, but each works with either GitOps controller.
Secrets without plaintext in Git
| Approach | What lives in Git | Where the key lives | Rotation | Good fit |
|---|---|---|---|---|
| Sealed Secrets | An encrypted SealedSecret | Controller's private key in the cluster | Re-seal and commit | One cluster, small team |
| SOPS with age or KMS | Encrypted values inside YAML | age key in the cluster, or a cloud KMS key | Re-encrypt and commit | Flux users; multi-cluster with KMS |
| External Secrets Operator | A reference to a secret path | AWS Secrets Manager, Vault, Azure Key Vault, Google Secret Manager | In the external store; ESO syncs it | Most production setups |
Our default is External Secrets Operator. Nothing secret enters Git, rotation happens in the system your security team already manages, and the diff still shows which secret each service depends on.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: checkout-db
namespace: checkout-production
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: aws-secrets-manager
target:
name: checkout-db
creationPolicy: Owner
data:
- secretKey: DATABASE_URL
remoteRef:
key: production/checkout/database-urlWhichever approach you choose, back up the decryption keys. A lost Sealed Secrets controller key or age key makes every encrypted secret in the repository unrecoverable, and teams usually discover that during a cluster rebuild.
What goes wrong in production
Self-heal fights another controller. If a manifest sets replicas: 3 and a HorizontalPodAutoscaler scales to 12, self-heal sets it back to 3, the HPA scales up again, and the loop never ends. Remove replicas from manifests of autoscaled workloads, or tell Argo CD to ignore the field:
spec:
ignoreDifferences:
- group: apps
kind: Deployment
jsonPointers:
- /spec/replicas
syncPolicy:
syncOptions:
- RespectIgnoreDifferences=trueThe same pattern shows up with mutating webhooks, injected sidecars and operators that write to objects they own. An application that is permanently OutOfSync teaches people to ignore OutOfSync, which is worse than having no signal.
Prune deletes something stateful. Rename a directory, move a service between Applications, or delete a namespace manifest, and prune removes the old resources, PersistentVolumeClaims included. Protect stateful objects explicitly with argocd.argoproj.io/sync-options: Prune=false in Argo CD or kustomize.toolkit.fluxcd.io/prune: disabled in Flux, and set the reclaim policy of production storage classes to Retain.
The emergency fix gets reverted. At 3 a.m. someone runs kubectl set image to roll back, and self-heal restores the broken version a few minutes later. Put the procedure in the runbook: revert the commit, or suspend reconciliation first (argocd app set checkout-production --sync-policy none or flux suspend kustomization checkout), fix, commit, then resume. Argo CD's own argocd app rollback refuses to run while automated sync is enabled, which is the right behavior.
Git or the registry is down. Controllers keep the last applied state, so running workloads are unaffected, but nothing new deploys. A node replacement that needs to pull an image from an unavailable registry is the real risk. Run a pull-through cache or registry mirror for production clusters.
CRDs race their consumers. A chart's custom resources fail because the CRD is not installed yet. Install CRDs from the infrastructure layer, then use sync waves in Argo CD or dependsOn with wait: true in Flux.
The GitOps controller is your most privileged workload. It can create anything anywhere it deploys. Lock it down: SSO only, no shared admin password, AppProjects that deny cluster-scoped resources by default, branch protection and required reviews on the deploy repo, and signed-commit verification (sourceIntegrity on an Argo CD AppProject, which replaced signatureKeys in Argo CD 3.5, and spec.verify on a Flux GitRepository). Anyone who can merge to main in the deploy repo can change production, so treat merge rights as production access. Our platform engineering guide covers how to package these guardrails for product teams.
Image automation creates commit storms. A busy registry plus a wide semver range produces a stream of bot commits, each triggering a sync. Narrow the policy, lengthen the interval, and keep automation on non-production paths.
GitOps also has a boundary. Databases, queues, DNS and IAM usually live outside the cluster and are better served by Terraform's plan and apply workflow; our Terraform production guide covers that half.
Frequently Asked Questions
What is GitOps deployment?
GitOps deployment is a model in which the desired state of each environment is declared in a Git repository, and an agent in the cluster, such as Argo CD or Flux, pulls that state and continuously reconciles the cluster to match it. Deploying means merging a commit. Rolling back means reverting one.
Is Argo CD better than Flux?
Neither is better in general. Argo CD is the stronger choice when teams need a web UI and one instance manages many clusters. Flux is the stronger choice when you want each cluster to be self-sufficient, real Helm releases, built-in SOPS decryption and image automation.
Do I still need a CI pipeline with GitOps?
Yes. CI still runs tests, builds and scans the image, and pushes it to a registry. What changes is that CI stops deploying: it proposes a change to the deployment repository, and the GitOps controller applies it after the merge.
How do you handle secrets in GitOps?
Never commit plaintext secrets. Most production teams use External Secrets Operator, which keeps only a reference in Git and syncs the value from a store such as AWS Secrets Manager or Vault. SOPS and Sealed Secrets encrypt values in Git instead, which works well as long as you back up the decryption keys.
How do you roll back a GitOps deployment?
Revert the commit that introduced the change and let the controller sync it, which keeps Git and the cluster in agreement. For canary releases, Argo Rollouts and Flagger abort automatically when analysis fails. Avoid manual kubectl changes during an incident unless you suspend reconciliation first, or self-heal will undo them.
Can GitOps manage infrastructure outside Kubernetes?
Partly. Crossplane, AWS Controllers for Kubernetes and Google Config Connector let Argo CD or Flux reconcile cloud resources as Kubernetes objects. Many teams keep cloud infrastructure in Terraform with its own plan and apply pipeline, and use GitOps for what runs inside the cluster.
Need help with GitOps?
We design and run GitOps platforms for teams that want deploys to be boring: repository layout, Argo CD or Flux, progressive delivery, secrets, and the runbooks for the night it breaks. If we built it, we are on the pager for it, and every engagement starts with a call with the engineer who would do the work.