Published on

GitOps push vs pull — how the trust direction flips with ArgoCD

Authors
  • avatar
    Name
    Krzysztof Kozłowski
    Twitter

For a long time, "deploy" meant one thing to me: a CI pipeline that ran kubectl apply against the cluster.

To do that, the pipeline needed cluster credentials. A kubeconfig sitting in a CI secret, with enough access to change production. I didn't think about it much. Everyone did it that way.

Then I started reading about GitOps, and the part that actually clicked wasn't "Git is the source of truth". I'd heard that line a hundred times and it never landed.

What landed was smaller and sharper: the direction of trust flips.

In the push model, CI reaches into the cluster. In the pull model, the cluster reaches out to Git. That one change moves a surprising number of problems at once.

The two directions

Same goal — get the desired state running in the cluster. Opposite flow.

GitOps push vs pull: in the push model, a CI pipeline holding cluster credentials runs kubectl apply into the cluster; in the pull model, an in-cluster ArgoCD or Flux controller watches the Git repository and continuously reconciles the cluster to match.

In push, the arrow points into the cluster, and it carries credentials.

In pull, the arrow points out of the cluster, toward Git. Nothing outside needs cluster access to deploy.

The push model — CI reaches in

The traditional flow looks like this:

  1. Developer commits code.
  2. CI builds, tests, and packages an image.
  3. CI runs kubectl apply or helm upgrade against the cluster.
  4. To do step 3, CI holds a kubeconfig with cluster access.

A deploy is an action. You run it, it succeeds or fails, and then it's over. Nothing keeps watching after that.

That last part is the quiet problem. Once the action finishes, nothing checks whether the cluster still matches what you deployed.

The pull model — the cluster reaches out

In GitOps, a controller runs inside the cluster. ArgoCD or Flux. It watches a Git repository that holds the desired state — manifests, image tags, Helm values.

The flow becomes:

  1. Developer commits application code.
  2. CI builds, tests, and packages an image.
  3. CI commits the new image tag to the GitOps repo (just a Git write, no cluster access).
  4. The controller notices the change, pulls it, and reconciles the cluster to match.

Reconciliation is continuous. The controller doesn't deploy once and walk away — it keeps comparing the cluster to Git, forever.

A deploy is no longer an action. It's a desired state that the cluster is constantly pulled toward.

Here's the object that ties a path in Git to a place in the cluster:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-api
  namespace: argocd
spec:
  source:
    repoURL: https://github.com/clusternotes/platform
    path: apps/my-api
    targetRevision: main
  destination:
    server: https://kubernetes.default.svc
    namespace: my-api
  syncPolicy:
    automated:
      prune: true # delete what Git no longer declares
      selfHeal: true # undo out-of-band changes

selfHeal: true is the line that makes the cluster reach back to Git on its own.

What actually changes

Five things move when you flip the direction.

Smaller attack surface. CI/CD pipelines no longer need direct cluster credentials. The kubeconfig comes out of the CI secrets entirely. Fewer places hold the keys to production.

Drift becomes visible. If someone changes a GitOps-managed resource outside Git — a quick kubectl edit at 2 AM — the controller notices. It reports the drift, and with selfHeal it reverts it. (Resources not under GitOps are out of scope — it only watches what it manages.)

Git becomes the audit log. Every change to the cluster is a commit. Author, timestamp, diff. You get the full history of "who changed what, when" for free, because there's no other way to change things.

Rollback through git revert. Reverting a commit usually reverts the cluster. No special tooling, no "redeploy the old pipeline".

Reconciliation is continuous. Not a one-shot at deploy time. The cluster is held at the desired state between deploys, not just at the moment of one.

Of these five, pull fully solves the attack surface, drift visibility, and audit trail. Rollback and reconciliation it improves, but with caveats I'll get to — stateful workloads don't roll back just because Git did.

But wait — does pull replace CI?

No. This trips people up.

GitOps replaces the deploy step, not the whole pipeline. Build, test, security scans, image publishing — all of that still runs in CI exactly as before. Only the single step that talks to the cluster moves to the controller.

CI's job gets smaller, not gone. It ends at "commit the new image tag to Git".

But wait — what about one-off tasks?

Imperative work doesn't fit the pull model, and that's fine.

A one-time database migration. A manual smoke test. An ad-hoc debug pod. These are actions, not desired state, and you still run them with kubectl and a real credential. Pull doesn't replace push for imperative tasks — it replaces it for declarative deploys.

ArgoCD vs Flux

Both implement the same pull-based reconciliation. The difference is in the experience.

ArgoCD ships with a rich web UI. You see applications, sync status, and drift on a dashboard. It uses Application custom resources, and scales to many apps with the App-of-Apps pattern (one parent Application that creates the rest).

Flux is more CLI- and Git-native. No dashboard out of the box — you read state through kubectl and the flux CLI. Lighter, more composable, very at home in a pure-Git workflow.

Same model. Pick based on whether your team wants a UI or a CLI.

What you removed

After the switch, a few things disappear from the system:

  • The kubeconfig in CI secrets.
  • Cluster credentials in pipeline service connections.
  • The "who deployed what" tribal knowledge — it's in Git history now.
  • Manual kubectl apply runbooks.
  • The gap between "what we think is deployed" and "what's actually running".

That last one is the real payoff. The question "is the cluster in the state we intended?" stops being a guess.

Pitfalls I hit

Thinking GitOps means no credentials anywhere. It doesn't. Humans, Terraform, and bootstrap tooling still need cluster access. GitOps removes them from the CI pipeline specifically — that's the win, and it's narrower than "no secrets".

Expecting git revert to undo a database migration. It won't. Stateful workloads, schema changes, and CRD upgrades still need their own rollback plan. Reverting Git reverts the manifests, not the data.

Forgetting drift detection has a scope. It only watches GitOps-managed resources. Anything created by hand outside Git is invisible to it.

Two sources of truth fighting. Someone kubectl edits a resource, selfHeal reverts it, the change vanishes, and the person is confused why their fix "didn't stick". The cluster is doing exactly what you told it.

Image automation loops. A bot writing image tags back to Git can trigger its own reconciliation in a loop if you're not careful with paths and triggers.

The bootstrap chicken-and-egg. The controller itself has to be installed somehow — and that first install is a push. You can't GitOps your way to the thing that does GitOps. Something has to plant it.

Key takeaways

  • Push: CI reaches into the cluster. Pull: the cluster reaches out to Git.
  • The security win is concrete — cluster credentials come out of CI.
  • Drift becomes visible, and Git becomes the cluster's audit log.
  • GitOps replaces the deploy step, not CI itself.
  • git revert rolls back stateless workloads cleanly; stateful still needs a plan.
  • Push still has a place: one-off, imperative tasks.

What's next

Once you have one Application working, the next question is "how do I manage dozens without clicking create by hand for each one". That's the App-of-Apps pattern — a parent Application whose only job is to create the others — and after that, sync waves for controlling deploy order so your CRDs land before the apps that need them.

If I'd understood the trust-direction flip before my first GitOps setup, I'd have spent a lot less time worrying about where the kubeconfig lived. That's the whole point of writing this down.

I post about this kind of thing on LinkedIn — come tell me how your push-to-pull migration actually went.