Home/Blog

GitOps Deployment Strategies with Argo CD and Flux

How GitOps deployment works in production: repo layout, working Argo CD and Flux manifests, canary rollouts, secrets, and the failure modes to plan for.

GitOps Deployment Strategies with Argo CD and Flux

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 kubectl fix 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 credentialsCI system, for every clusterA controller inside each cluster
Drift from manual changesUnnoticed until the next deployDetected, then reverted or flagged
Audit trailCI logs, if retainedGit history plus controller events
RollbackRe-run an old pipelinegit revert
Cluster on a private networkNeeds inbound access from CINeeds only outbound access to Git and the registry
CI outageBlocks all deploysBlocks new images; the cluster keeps converging

The full loop for one service:

Loading diagram...

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.yaml

Promotion 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 HEAD

Argo 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 argocd

Pin 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: NetworkPolicy

The 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: 3m

prune 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=true

When 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-key

The 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-age

wait: 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: Setters

A 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 CDFlux
ArchitectureCentral server: API, UI, repo server, application controllerIndependent controllers in each cluster, no server
UIFull web UI with diff, sync and historyFlux Operator web UI; most teams work from Git and the CLI
Multi-clusterOne instance manages many clusters (hub and spoke)Usually one Flux per cluster, each pulling its own path
Access controlOwn RBAC model plus SSO, scoped by AppProjectKubernetes RBAC; a Kustomization can impersonate a service account
HelmRenders charts to manifests; helm list shows nothingReal Helm installs and upgrades with release history
Secrets in GitThrough plugins or External Secrets OperatorNative SOPS decryption
Image automationArgo CD Image Updater, a separate projectBuilt in, v1 APIs
Progressive deliveryArgo Rollouts, which replaces the Deployment with a RolloutFlagger, which wraps an existing Deployment
Blast radius if compromisedThe hub holds credentials for every cluster it managesEach cluster's Flux holds only its own access
Project statusCNCF graduatedCNCF 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.

StrategyMechanismWhat the Git commit doesRollbackNeeds
Rolling updateDeployment replaces pods graduallyChanges the image digestgit revertHonest readiness probes
Blue-greenTwo ReplicaSets; a Service switches between themChanges the digest; promotion flips the active ServiceFlip back instantlyArgo Rollouts or Flagger, double capacity during the switch
CanaryA share of traffic or pods runs the new version; analysis gates each stepChanges the digest; steps run automaticallyAutomatic abort on failed analysisArgo 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

ApproachWhat lives in GitWhere the key livesRotationGood fit
Sealed SecretsAn encrypted SealedSecretController's private key in the clusterRe-seal and commitOne cluster, small team
SOPS with age or KMSEncrypted values inside YAMLage key in the cluster, or a cloud KMS keyRe-encrypt and commitFlux users; multi-cluster with KMS
External Secrets OperatorA reference to a secret pathAWS Secrets Manager, Vault, Azure Key Vault, Google Secret ManagerIn the external store; ESO syncs itMost 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-url

Whichever 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=true

The 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.