Ask a team running Kubernetes what is actually deployed in their production cluster right now, and most cannot tell you with certainty. The manifests in the repository are a close guess, not a guarantee, because the last few changes went out as someone running kubectl apply or helm upgrade from a pipeline, and nobody recorded exactly what landed.
GitOps has become the mainstream answer to that problem. In the 2025 CNCF Argo CD End User Survey, Argo CD runs in nearly 60 per cent of Kubernetes clusters for application delivery, with 97 per cent of respondents using it in production, up from 93 per cent in 2023 (CNCF, 2025). It is no longer a niche pattern.
The shift is from pushing changes into the cluster to letting the cluster pull its desired state from Git. This post shows how continuous delivery to Kubernetes actually works with Argo CD and Helm: the Application that points at your chart, the sync policy that heals drift, the pattern that scales it to many services, and how your GitHub Actions pipeline changes from running the deploy to simply committing.
Push-based delivery is why your cluster drifts
The common setup is straightforward. Continuous integration builds an image, then runs helm upgrade or kubectl apply against the cluster. The pipeline pushes the change in. It works, and that is exactly why it is hard to give up.
# The push pattern we are moving away from
- name: Deploy
run: |
helm upgrade --install web ./charts/web \
--namespace prod \
--set image.tag=${{ github.sha }}
The problem is what happens after the command finishes. Nothing keeps the cluster matching Git. Someone runs a quick edit to get through an incident, a different pipeline applies a change that never made it back to the repository, and the live state quietly diverges from what is written down. As one engineer put it, you end up back at "I think I ran helm upgrade last month" (Djerbi, 2025). Rollback becomes rerunning an old job and hoping it still produces what was live before.
Push is fine for a single environment you rarely touch. It stops being fine the moment you have several environments, several people with cluster access, or incidents where someone changes the cluster directly. At that point the gap between Git and reality is no longer a guess you can live with.
The Argo CD Application makes Git the source of truth
Argo CD runs as a controller inside the cluster. You declare an Application that points at a Git repository holding your Helm chart, and a destination cluster and namespace, and Argo CD continuously reconciles the live state to match what Git says (CNCF, 2025). The desired state now lives in Git, versioned and reviewable, and the cluster pulls from it. A deploy becomes a commit, not a command.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: web
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/your-org/gitops.git
targetRevision: main
path: charts/web
helm:
valueFiles:
- values-prod.yaml
destination:
server: https://kubernetes.default.svc
namespace: prod
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
The source block points at the chart and its production values file. The destination is the target cluster and namespace. The syncPolicy.automated block is what turns this from a one-off apply into continuous delivery, and CreateNamespace=true creates the namespace if it is missing (Zafar, 2026).
The trade-off is indirection. You now maintain a separate configuration repository and have to keep image tags flowing into it, rather than passing them straight to a deploy command. That extra step is the price of auditability, and for anything beyond one environment it pays for itself quickly.
Self-heal and prune: the cluster corrects itself
Two flags make GitOps continuous rather than one-shot. selfHeal reverts manual drift back to the Git state. prune deletes resources you have removed from Git, which by default Argo CD leaves alone as a safety measure (Argo CD docs). Together they turn Git from a record into something that is actively enforced.
# Someone "fixes" prod by hand
kubectl scale deployment/web --replicas=10 -n prod
# Argo CD sees the drift and reverts to the replica count in Git.
# selfHeal retries after the self-heal timeout (5s by default),
# set on the application controller via:
# --self-heal-timeout-seconds
argocd app diff web
The scale command changes the live state. Argo CD's reconciliation notices the difference from Git and re-applies the declared version. Self-heal retries after a short timeout, five seconds by default, and the periodic reconcile, roughly every three minutes by default, catches drift even when nobody has changed Git (Markaicode, 2026). The replica count returns to what the chart says without anyone intervening.
The trade-off is that self-heal is aggressive. If you genuinely need a temporary manual change during an incident, Argo CD will fight you and put it back. The right answer is to make the change in Git, or to pause auto-sync for that one application, rather than turning off the safety net across the board.
ApplicationSet, instead of one file per service
Writing an Application by hand for every microservice does not scale. ApplicationSet generates Applications from a generator, such as a directory of overlays in Git, so one resource manages many services and each picks up the same sync policy (KubeAce, 2025).
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: microservices
namespace: argocd
spec:
generators:
- git:
repoURL: https://github.com/your-org/gitops.git
revision: main
directories:
- path: "apps/prod/*"
template:
metadata:
name: "{{path.basename}}"
spec:
project: default
source:
repoURL: https://github.com/your-org/gitops.git
targetRevision: main
path: "{{path}}"
destination:
server: https://kubernetes.default.svc
namespace: "{{path.basename}}"
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
The git directory generator scans apps/prod/* and creates one Application per directory, naming it after the directory and deploying it into a matching namespace. Onboarding a new service becomes adding a folder, not hand-writing another Application (KubeAce, 2025).
The trade-off is a layer of indirection that is harder to debug when a generator misbehaves, and a careless change to the ApplicationSet can affect every application at once. Keep it under the same review discipline as the rest of your manifests, because its blast radius is larger.
The pipeline's new job: build, then commit
With Argo CD doing the deploy, the pipeline no longer touches the cluster. It builds and pushes the image, then commits the new image tag into the GitOps repository. Argo CD takes it from there. This is the separation GitOps is built on: application source in one repository, deployment configuration in another, with the pipeline bridging them through a commit. GitOps does not replace continuous delivery, a point worth stressing because it is often misread as either-or (Octopus, 2025).
jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push image
run: |
docker build -t ghcr.io/your-org/web:${{ github.sha }} .
docker push ghcr.io/your-org/web:${{ github.sha }}
- name: Bump image tag in GitOps repo
env:
TOKEN: ${{ secrets.GITOPS_TOKEN }}
run: |
git clone https://x-access-token:$TOKEN@github.com/your-org/gitops.git
cd gitops
yq -i '.image.tag = "${{ github.sha }}"' charts/web/values-prod.yaml
git commit -am "web: deploy ${{ github.sha }}"
git push
The job builds and pushes the image, then updates the image tag in the chart's production values in the GitOps repository and commits it. Argo CD sees the new commit and rolls the change out. The deploy is now an auditable Git history rather than an ephemeral pipeline log.
git revert, and Argo CD reconciles the cluster back to the previous state on its own. No rerun of an old pipeline, and no guessing what was live before.The trade-off is access. The pipeline needs a token that can write to the GitOps repository, and you need a clear convention for who commits image tags. Many teams hand that job to a bot or an image updater rather than letting the pipeline write directly. Either way the principle holds: the pipeline produces a commit, not a deployment.
The part worth sitting with
So go back to the question this started with. What is actually running in your production cluster right now? With a push-based pipeline, the honest answer is that you are fairly sure, based on the last command someone remembers running. With Git as the source of truth and a controller reconciling against it, the answer is whatever the latest commit says, and you can prove it. Nearly sixty per cent of Kubernetes clusters have already made that switch. The teams that have not are not avoiding complexity. They are deferring it to the next incident, when they reach for a rollback and find that nobody can say what they are rolling back to.
Author note
I am Mohan Gopi, an Associate DevOps Engineer at Frigga Cloud Labs, working across AWS, GCP, and Azure with GitHub Actions as my deployment backbone. I wrote this because the question that opens this post is the one that exposes most Kubernetes setups: nobody can say with certainty what is live. The pattern I keep seeing is a pipeline that runs helm upgrade, a cluster that quietly drifts, and a rollback plan that amounts to rerunning an old job. GitOps is not about the tooling being fashionable. It is about being able to answer, at any moment, exactly what is deployed and why. Let us connect on LinkedIn → Mohan Gopi.
