Argo CD Lesson 14 of 45

Diffing & Drift: ignoreDifferences, Server-Side Diff, Mutating Webhooks & Perpetual OutOfSync

There is one Argo CD problem that shows up on every platform team’s Slack sooner or later, and it always sounds the same: “This app is stuck OutOfSync. I’ve synced it five times. It goes green for a second, then flips straight back. Nothing in Git changed. What is going on?” This is perpetual OutOfSync, and it is not a bug in Argo CD. It is Argo CD doing exactly its job — reporting that the live cluster no longer matches Git — while something else in the cluster keeps quietly editing a field Argo never set.

That “something else” is almost always another controller doing legitimate work: a HorizontalPodAutoscaler adjusting replicas, a mutating webhook injecting a service-mesh sidecar, an admission controller stamping a default, cert-manager rewriting a CA bundle inside a Secret. Argo renders your manifest, sees a field on the live object that isn’t in your manifest, and honestly calls it drift. If you have automated self-heal on, Argo now reverts that field, the other controller re-applies it, and the two of them war forever — a flap you can watch in real time.

The fix is never to disable self-heal and paper over the whole thing. The fix is to teach Argo which fields it does not own, so the diff ignores them and only real drift trips the verdict. This lesson is about the three tools for that job — ignoreDifferences, managedFieldsManagers, and Server-Side Diff — and, just as importantly, about how the diff actually works so you can tell which tool a given problem needs. Every manifest here is schema-correct for Argo CD 2.13+/3.x on Kubernetes 1.29+, and the lab reproduces the HPA flap end-to-end and fixes it three different ways so you can compare them.


Why this matters

Argo CD’s entire value proposition rests on one comparison: does the live cluster match the desired state in Git? That comparison produces the Synced / OutOfSync verdict you see on every app. When the comparison is right, Argo is a truth machine — it catches every out-of-band kubectl edit, every manual scale, every drift, and (with self-heal) drags the cluster back. When the comparison produces false positives, the whole thing inverts: your dashboard is a wall of yellow, real drift hides in the noise, and engineers learn to ignore Argo — which defeats the point of running it.

Perpetual OutOfSync is the number-one cause of that false-positive noise, and it comes from a genuine tension in Kubernetes: many controllers legitimately share ownership of a single object. You write a Deployment with replicas: 2 and commit it. Then an HPA takes over replicas because scaling is its job. Then a mesh webhook adds a sidecar container because injection is its job. The object in the cluster is now a blend of your fields and their fields — but Git only has your fields. A naive field-by-field diff flags every field the others added.

The mental model to hold for the whole lesson is this: the diff is only as good as its notion of “what Argo owns.” A diff that assumes Argo owns every field on the object will fight every other controller in the cluster. A diff that correctly scopes to only the fields Argo actually set will sit quietly Synced while HPAs, webhooks and defaulters do their thing. Everything below — ignoreDifferences, managed fields, Server-Side Diff — is a different way of drawing that ownership boundary. Getting it right is the difference between a GitOps platform people trust and one they route around.

This lesson assumes you already know the two status axes from Sync Status & Health Assessment — that OutOfSync is about matching Git, not about whether pods are healthy — and it pairs tightly with Sync Policies: Automated Sync, Self-Heal & Prune, because self-heal is what turns a harmless false diff into an active flap.


How Argo CD actually computes the diff

Before you can fix a bad diff you have to know how the diff is produced. The Sync Status lesson introduced the five-step pipeline; here we go deeper on the two steps that produce false positives — normalize and compare — because that is where every fix in this lesson plugs in.

The application-controller runs this loop for every managed resource:

Step What happens Which component Where diffs break
1. Render desired The repo-server renders your source at targetRevisionhelm template, kustomize build, or plain YAML — into concrete manifests. repo-server A template that emits a field you didn’t intend adds it to desired.
2. Fetch live The controller reads the current live objects from its cluster cache. application-controller The live object carries fields other controllers wrote.
3. Normalize both sides System metadata, status, server defaults, and anything under ignoreDifferences are stripped from both sides so they don’t show as spurious diffs. application-controller This is the step you tune. A field not normalized here becomes a false diff.
4. Compare Field by field, normalized-desired vs normalized-live, as a structured merge. application-controller A field present in live but absent in desired reads as drift.
5. Verdict Every managed resource equal → Synced; any differ → OutOfSync; can’t render/compare → Unknown (a ComparisonError). application-controller

Two properties of this pipeline cause nearly every surprise:

The two operands are not symmetric. Desired is the rendered output of your Git source — a small, tidy set of fields you actually declared. If you build with Kustomize or Helm, that means the diff runs against the kustomize build / helm template output, not your raw overlay or values files, so a change that alters the rendered result flips you OutOfSync while a comment does not (the Kustomize Integration lesson covers what that rendering step emits). Live is the full object as the API server returns it — your fields plus every default the server filled in plus every field other controllers mutated plus system metadata. The diff’s whole job is to decide which of those extra live fields count.

Desired (Git side) Live (cluster side)
Source Rendered manifests from the repo-server The object read back from the Kubernetes API
Contains Only fields you declared in your manifest Your fields + server defaults + other controllers’ fields + metadata
replicas on a Deployment 2 (what you wrote) 5 (what the HPA wrote)
A sidecar container absent present (injected by a webhook)
status, creationTimestamp, managedFields absent present (Argo strips these automatically)
Who “should” own each field Argo CD shared: Argo + HPA + webhooks + the API server

Normalization is where the false positives live and die. Argo automatically removes a set of fields that are never meaningful to compare — this built-in normalization is why your apps aren’t permanently OutOfSync on metadata.resourceVersion:

Field / category Why it’s stripped Handled by
status (whole subtree) Live-only; written by controllers, never by you Built-in, always
metadata.creationTimestamp, uid, generation, resourceVersion, selfLink Server-assigned bookkeeping Built-in, always
metadata.managedFields Server-Side Apply’s ownership ledger; noise in a diff Built-in, always
kubectl.kubernetes.io/last-applied-configuration annotation Legacy client-side-apply artifact Built-in, always
Quantity formats (1 vs 1000m, 1Gi vs 1073741824) Semantically equal, textually different Built-in known-type normalizers
Fields another controller owns (replicas, injected sidecar, CA bundle) Not stripped by default — Argo can’t guess these You, via ignoreDifferences / SSA / Server-Side Diff

The last row is the entire problem. Argo can strip fields it knows are noise, but it cannot guess that your HPA owns replicas or that your mesh injects istio-proxy. Those are cluster-specific facts only you know — so those are the fields you have to declare. The diagram below is the whole engine in one picture: desired and live flow into the controller, the diff runs field-by-field, and the three filters remove the fields Argo doesn’t own before the verdict is struck.

Left-to-right Argo CD diff engine: a Git repo and its repo-server-rendered manifests form the desired side while a live Kubernetes cluster mutated by other controllers such as an HPA and a mutating webhook forms the live side; both feed the application-controller which normalizes and computes a field-by-field diff, then three filters — ignoreDifferences with jsonPointers or jq, managedFieldsManagers by field-manager, and Server-Side Diff via an API dry-run apply — drop the non-owned fields so that only real drift trips the final Synced versus OutOfSync verdict

The badges mark the load-bearing ideas: another controller owns a field Argo never set (1); the naive diff flags it as drift and the app goes perpetually OutOfSync (2); a scoped ignoreDifferences drops that exact field (3); managedFieldsManagers drops everything a named controller owns (4); Server-Side Diff makes the API server account for defaults and webhooks automatically (5); and with any filter in place only real drift trips the verdict, so you never resort to disabling self-heal (6).


Perpetual OutOfSync: who keeps changing your fields

When an app is stuck OutOfSync and re-syncing doesn’t help, the diagnosis is always the same shape: a field exists on the live object that isn’t in your rendered manifest, and something keeps putting it back. The skill is identifying which field and which actor. Here are the offenders you will actually meet, ranked by how often they generate tickets:

Cause What mutates the object Field(s) that drift Symptom in argocd app diff
HPA owns replicas HorizontalPodAutoscaler via the scale subresource /spec/replicas - replicas: 5 (live) vs + replicas: 2 (Git); flaps under self-heal
Mesh sidecar injection Istio / Linkerd / OSM mutating webhook injected container, initContainer, volumes, annotations Whole istio-proxy container appears in live, absent in Git
Defaulting admission controller API server or a custom mutating webhook securityContext, imagePullPolicy, terminationMessagePath, dnsPolicy Small fields present in live, never written by you
Server-side defaults Kubernetes API server on create protocol: TCP on ports, revisionHistoryLimit, strategy defaults Defaulted fields show as “added” in live
cert-manager / CA injector cert-manager ca-injector, kube CA bundle /data/ca.crt in a Secret, caBundle in a webhook config A Secret or webhook config is eternally OutOfSync
Cloud resource defaulting Cloud LB/ingress controller /spec/loadBalancerClass, finalizers, status annotations on Service/Ingress Finalizers/annotations appear on a Service you didn’t set
Metadata / annotation churn Operators writing bookkeeping annotations /metadata/annotations/* (last-reconcile timestamps, revisions) An annotation value changes every reconcile
CRD with a defaulting/conversion webhook The CRD’s own webhook fields the CRD defaults on admission A custom resource never reaches Synced

Two of these deserve extra attention because they trip up almost everyone.

The HPA case is the canonical one. You declare replicas: 2 in Git. The HPA’s job is to own the replica count, so it writes replicas: 5 (or whatever the metrics demand) directly onto the Deployment through the scale subresource. Now desired.spec.replicas = 2 and live.spec.replicas = 5 — a permanent, legitimate disagreement. Without a filter, the app is OutOfSync forever. With self-heal on, it’s worse: Argo sets it back to 2, the HPA immediately scales it back to 5, and you get a visible flap every reconciliation interval. This is the single most common perpetual-OutOfSync ticket in existence, and it’s the one we reproduce in the lab.

Sidecar injection is the second. A service mesh installs a mutating admission webhook that intercepts pod creation and injects an istio-proxy (or linkerd-proxy) container, an init container, and volumes — none of which are in your Deployment manifest. Argo renders your two-container Deployment, reads back a three-container Deployment, and flags the whole injected container as drift. You cannot fix this by editing your manifest (you don’t want the sidecar in Git — the mesh owns it), so you must tell the diff to ignore what the webhook owns.

The cloud edge: which webhooks mutate your objects

The diff engine itself is completely cloud-neutral — the same normalization runs identically on AKS, EKS and GKE. But the set of controllers mutating your objects is very much cloud-specific, because each managed Kubernetes platform ships its own admission webhooks for identity, load balancing, and (on Autopilot) resource sizing. If you run the same manifest on all three clouds, you will hit different false diffs on each:

Cloud Mutating webhook / controller What it injects or rewrites Cleanest filter
AKS Azure Workload Identity webhook (azure-wi-webhook) Projected SA-token volume + AZURE_* env on pods labelled azure.workload.identity/use: "true" ServerSideDiff=true, or jqPathExpressions on the injected env/volumes
AKS Open Service Mesh / Istio sidecar injector envoy/istio-proxy container + init container + volumes jqPathExpressions selecting the sidecar, or its managedFieldsManagers
AKS Application Gateway Ingress Controller (AGIC) Annotations + finalizers on Ingress ignoreDifferences on /metadata/finalizers and the annotation keys
EKS EKS Pod Identity / IRSA webhook (pod-identity-webhook) AWS_* env, eks.amazonaws.com/* annotations, projected token volume ServerSideDiff=true, or jqPathExpressions on env/volumes
EKS AWS Load Balancer Controller Finalizers + status on Ingress/Service (elbv2.k8s.aws/*) ignoreDifferences on /metadata/finalizers; status is auto-stripped
GKE GKE Autopilot resource-defaulting webhook Adjusts container resources.requests/limits to Autopilot minimums ServerSideDiff=true, or jqPathExpressions on resources
GKE Anthos Service Mesh / Istio injector istio-proxy sidecar + volumes jqPathExpressions selecting the sidecar
GKE Config Connector / Workload Identity Bookkeeping annotations, iam.gke.io/* on the SA ignoreDifferences on the annotation keys

The practical takeaway is the reason Server-Side Diff exists: on managed clouds you cannot enumerate every webhook by hand, and the set changes as you enable features. GKE Autopilot silently rewriting your resource requests is a textbook example — you never wrote those values, Autopilot did, and no amount of editing your manifest makes the diff go away. The server-side approach lets the API server tell Argo what the object would look like after all these webhooks run, so the injected fields simply match. We’ll get there; first, the surgical tool.


ignoreDifferences, field by field

ignoreDifferences is a list on the Application spec — the same spec whose source and destination anatomy the first Application lesson walks through — or a global rule in argocd-cm, covered later. Each entry names a resource and then names the fields on it to drop from the diff. It has exactly these fields:

Field Required Meaning
group yes API group of the resource (apps, "" for core, autoscaling, …)
kind yes Kind (Deployment, Secret, HorizontalPodAutoscaler, …)
name no Restrict to one named resource; omit to match all of that kind
namespace no Restrict to one namespace; omit to match all namespaces
jsonPointers no List of RFC 6901 JSON Pointers to the exact fields to ignore
jqPathExpressions no List of jq expressions selecting fields/elements to ignore
managedFieldsManagers no List of field-manager names whose owned fields to ignore

You supply at least one of the three selection mechanisms (jsonPointers, jqPathExpressions, managedFieldsManagers). They can be combined. Here is the canonical HPA fix — the smallest possible change that stops the flap:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: web
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/acme/web.git
    targetRevision: main
    path: manifests
  destination:
    server: https://kubernetes.default.svc
    namespace: web
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
  ignoreDifferences:
    - group: apps
      kind: Deployment
      name: web              # scope to just this Deployment
      jsonPointers:
        - /spec/replicas     # the HPA owns this — stop diffing it

jsonPointers, jqPathExpressions, managedFieldsManagers — which to reach for

These three are not interchangeable; each is best at a different shape of problem.

Mechanism Selects by Best for Weakness
jsonPointers Exact structural path (RFC 6901) A single, stable field like /spec/replicas Can’t select array elements by value; brittle if indices shift
jqPathExpressions jq query (can filter by value/name) “The container named istio-proxy”, “any annotation matching X” More powerful but easier to get wrong; runs a jq engine per diff
managedFieldsManagers The field-manager that owns the field “Everything kube-controller-manager writes” — HPA, whole webhook injections Needs Server-Side Apply field ownership to be meaningful

jsonPointers use JSON Pointer syntax, which is worth knowing precisely because a wrong pointer silently does nothing — it matches no field, so the diff is unchanged and you think the feature is broken:

You want to ignore JSON Pointer
spec.replicas /spec/replicas
The image of the first container /spec/template/spec/containers/0/image
A Secret’s ca.crt data key /data/ca.crt
An annotation with a slash in its key (app.io/rev) /metadata/annotations/app.io~1rev

Note the escaping: ~1 means a literal / inside a key, and ~0 means a literal ~. Array elements are addressed by ordinal index (/0, /1), which is exactly why pointers are fragile for lists whose order can change — that’s the case for jqPathExpressions.

jqPathExpressions let you select by content, which is what you need for an injected sidecar (you don’t know its index, but you know its name):

  ignoreDifferences:
    - group: apps
      kind: Deployment
      jqPathExpressions:
        # ignore the whole injected sidecar container, wherever it lands in the list
        - '.spec.template.spec.containers[] | select(.name == "istio-proxy")'
        - '.spec.template.spec.initContainers[] | select(.name == "istio-init")'

managedFieldsManagers is the Server-Side-Apply-era answer, and it is usually the cleanest of the three because it matches the way Kubernetes already tracks ownership. Every field on an object records which manager last set it in metadata.managedFields. When the HPA writes replicas through the scale subresource, the manager recorded is kube-controller-manager. So instead of naming the field, you name the manager:

  ignoreDifferences:
    - group: apps
      kind: Deployment
      name: web
      managedFieldsManagers:
        - kube-controller-manager   # ignore every field the HPA controller owns

The advantage is that you don’t have to enumerate fields at all — if the controller starts writing a second field tomorrow, this rule already covers it. For a webhook-injected sidecar, you point it at the webhook’s manager name and the entire injection (container, volumes, annotations) is ignored in one line. The catch is that field ownership is only reliably tracked when writes go through Server-Side Apply, which modern controllers and the API server do — but it’s why managedFieldsManagers pairs naturally with ServerSideApply=true.

Here is the same three-way decision as a worked table you can copy from:

Real problem Filter that fits The ignoreDifferences entry
HPA scales replicas jsonPointers (single stable field) jsonPointers: [/spec/replicas]
HPA scales replicas, future-proof managedFieldsManagers managedFieldsManagers: [kube-controller-manager]
Mesh injects istio-proxy jqPathExpressions (select by name) .spec.template.spec.containers[] | select(.name == "istio-proxy")
cert-manager rewrites ca.crt in a Secret jsonPointers jsonPointers: [/data/ca.crt]
A whole controller’s mutations managedFieldsManagers managedFieldsManagers: [<that-controller>]
Everything defaulted/injected, don’t want to enumerate not ignoreDifferences — use Server-Side Diff (see below)

That last row is the honest guidance the rest of the lesson builds toward: when the list of things to ignore is long or unknowable, stop maintaining a list and let the server compute the diff.


Ignoring a diff is not pruning, and not self-heal

A subtle and important point that catches people: ignoreDifferences only affects the diff. It changes whether the app is reported OutOfSync. It does not, by default, change what a sync does, and it has nothing to do with prune. These three controls are orthogonal, and conflating them is a classic mistake:

Control What it governs What it does NOT do
ignoreDifferences The diff — whether an ignored field counts toward OutOfSync Does not stop a sync from writing that field; does not affect other fields
selfHeal Whether Argo auto-reverts detected drift back to Git Does not create or ignore diffs — it only acts on what the diff reports
prune Whether resources deleted from Git are deleted from the cluster Nothing to do with field-level diffs
RespectIgnoreDifferences=true Makes the sync itself also leave ignored fields alone Only meaningful alongside ignoreDifferences

The consequence worth internalizing: with a plain ignoreDifferences on /spec/replicas, your app shows Synced (good) — but the desired manifest still contains replicas: 2. So the next time a sync runs for any reason — you bump the image tag, someone clicks Sync, self-heal fires on a different field — Argo applies the full manifest including replicas: 2, momentarily stomping the HPA’s value before the HPA scales it back. The diff was ignored; the write was not.

If you need the sync itself to leave the field alone, add the RespectIgnoreDifferences=true sync option:

  syncPolicy:
    syncOptions:
      - RespectIgnoreDifferences=true
    automated:
      prune: true
      selfHeal: true
  ignoreDifferences:
    - group: apps
      kind: Deployment
      name: web
      jsonPointers:
        - /spec/replicas

Now both the diff and the sync skip /spec/replicas, and the HPA is never disturbed. One caveat to know: RespectIgnoreDifferences reliably honours jsonPointers and managedFieldsManagers; its support for jqPathExpressions has historically been limited, so when you need the sync (not just the diff) to leave a field alone, prefer jsonPointers or a managed-fields rule. And note what none of this changes: self-heal on every other field still works. Ignoring replicas does not disable drift correction for the image, the env vars, or anything else — which is exactly why “just set selfHeal: false” is the wrong fix. That switch disables drift correction for the entire app, blinding you to the real changes GitOps exists to catch.


Server-Side Diff: let the API server do the math

Everything above draws the ownership boundary by hand. Server-Side Diff draws it automatically, and it is the modern answer that removes whole categories of ignoreDifferences hacks.

The idea: instead of Argo rendering the desired manifest and diffing it against live itself (a “client-side” diff that has no idea what defaults or webhooks would do), Argo asks the API server to perform a dry-run Server-Side Apply of the desired manifest and return the object that would result. Because that dry-run runs the full admission chain — defaulting, mutating webhooks, and the SSA merge that respects other managers’ fields — the returned object already contains the server defaults, the injected sidecar, the Autopilot-adjusted resources, everything. Argo then diffs that against live. The injected and defaulted fields are present on both sides, so they simply match. No list to maintain.

Enable it per-app with the ServerSideDiff=true sync option (it pairs naturally with ServerSideApply=true):

  syncPolicy:
    syncOptions:
      - ServerSideApply=true
      - ServerSideDiff=true
    automated:
      prune: true
      selfHeal: true

Or turn it on fleet-wide via the controller parameter controller.diff.server.side: "true" in the argocd-cmd-params-cm ConfigMap (restart the application-controller to pick it up). When it’s on globally, you can opt a single resource out with the annotation argocd.argoproj.io/compare-options: ServerSideDiff=false.

Client-side vs server-side, head to head:

Client-Side Diff (default) Server-Side Diff (ServerSideDiff=true)
Who computes the diff The application-controller, locally The Kubernetes API server, via dry-run SSA
Accounts for server defaults No — defaults show as false diffs Yes — defaults are on both sides
Accounts for mutating webhooks No — injected fields show as drift Yes — the dry-run runs webhooks
Respects other field-managers Only if you list them Yes — SSA merge respects ownership
ignoreDifferences still needed? Often, and lots of it Rarely — mostly gone
Cost Cheap, local One dry-run apply per resource per diff (more API load)
When to use Simple apps, no webhooks/HPA Meshes, HPAs, Autopilot, defaulting webhooks, CRDs with webhooks

The honest ordering for a real platform is: reach for Server-Side Diff first. It fixes the largest class of problems (webhooks, server defaults, Autopilot resource mutation) with one flag and no per-field maintenance. Then, for the specific fields it doesn’t cover — chiefly the HPA replicas case, because the HPA writes through the scale subresource rather than something the dry-run reproduces — reach for managedFieldsManagers (kube-controller-manager), which is self-maintaining. Only drop to targeted jsonPointers when you need to name one exact field, or when you also need RespectIgnoreDifferences on the sync. And never reach for selfHeal: false to silence a flap — that’s not a fix, it’s turning off the smoke detector.

Priority Tool Fixes Reach for it when
1 Server-Side Diff Webhooks, server defaults, Autopilot resources, CRD defaulting Almost always — turn it on and see what’s left
2 managedFieldsManagers Whole controllers’ fields (HPA via kube-controller-manager) A named controller owns fields SSD doesn’t cover
3 jsonPointers / jqPathExpressions One exact field or one named element You need surgical scope, or RespectIgnoreDifferences
selfHeal: false Nothing — hides all drift Never, as a diff fix

Diff normalization and global rules

Everything so far was per-Application. When the same false diff hits many apps — say every Deployment in the fleet is behind the same defaulting webhook — you don’t want to copy ignoreDifferences into every Application. Put it once in the argocd-cm ConfigMap and it applies globally.

Global ignoreDifferences uses resource.customizations.ignoreDifferences.<group>_<kind> (note the _ separator between group and kind; core group is empty, so it’s just _Kind):

apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-cm
  namespace: argocd
data:
  # ignore HPA-owned replicas on EVERY Deployment, fleet-wide
  resource.customizations.ignoreDifferences.apps_Deployment: |
    managedFieldsManagers:
      - kube-controller-manager
    jsonPointers:
      - /spec/replicas
  # a rule that applies to ALL resource kinds
  resource.customizations.ignoreDifferences.all: |
    jsonPointers:
      - /metadata/annotations/kubectl.kubernetes.io~1last-applied-configuration

Two related argocd-cm keys round out normalization, and it’s worth knowing which does what because their names are similar:

argocd-cm key Purpose Diff or reconcile?
resource.customizations.ignoreDifferences.<group>_<kind> Global field-ignore rules (same shape as the per-app list) Diff — affects OutOfSync
resource.customizations.ignoreDifferences.all The same, applied to every kind Diff
resource.customizations.knownTypeFields.<group>_<kind> Tell Argo a field is a known type (e.g. a resource quantity) so 1 and 1000m compare equal Diff (normalization)
resource.compareoptions Global compare toggles (e.g. ignoreAggregatedRoles, ignoreResourceStatusField) Diff
resource.customizations.ignoreResourceUpdates.<group>_<kind> Ignore live updates to a field so they don’t trigger a reconcile (performance) Reconcile, not diff — don’t confuse with the above

knownTypeFields deserves a word because it fixes a specific and confusing false diff: quantity formatting. If a CRD stores a value that is semantically a resource quantity but Argo doesn’t know its type, 500m in Git and 0.5 in live look different. Declaring the field’s known type makes the normalizer parse both as quantities and compare them equal:

data:
  resource.customizations.knownTypeFields.example.com_MyResource: |
    - field: spec.cpuRequest
      type: core/v1/ResourceList

Finally, the RespectIgnoreDifferences=true sync option discussed earlier is the bridge between normalization and sync: normalization/ignoreDifferences decide what the diff ignores; RespectIgnoreDifferences extends that same ignore-set to the sync write. Keep the two ideas distinct and you’ll never be surprised by a sync stomping a field you thought you’d protected.


Debugging a diff

When an app is OutOfSync and you can’t see why from the UI, drive the diff from the CLI. argocd app diff prints the exact field-level difference the controller sees — it is the fastest way to identify which field and therefore which filter you need.

# Show the live-vs-desired diff for one app (exit code 1 if there IS a diff)
argocd app diff web

Representative output for the HPA case — labelled, since the shape is what matters:

# representative argocd app diff output
===== apps/Deployment web/web ======
5c5
<   replicas: 5
---
>   replicas: 2

Read it as: the < line is the live cluster value (replicas: 5, written by the HPA) and the > line is the desired value from Git (replicas: 2). That one field is the entire reason the app is OutOfSync — which immediately tells you to ignore /spec/replicas (or the HPA’s field-manager). The most useful argocd app diff flags:

Command / flag What it shows
argocd app diff <app> Live vs desired for every managed resource
argocd app diff <app> --refresh Force a fresh compare first (don’t trust the cache)
argocd app diff <app> --server-side-generate Generate manifests server-side (matches what the controller renders)
argocd app diff <app> --local <path> Diff live against manifests in a local directory (test a change before you commit)
argocd app diff <app> --revision <rev> Diff against a specific Git revision/tag/SHA
argocd app manifests <app> Dump the fully rendered desired manifests (see exactly what Argo will apply)
kubectl get <kind> <name> -o yaml | less + look at managedFields Find which manager owns the drifting field → feeds managedFieldsManagers

That last technique is the one that tells you which field-manager to name. To find out who owns replicas:

# Who last wrote the fields on this Deployment? Look for the scale subresource.
kubectl -n web get deploy web --show-managed-fields -o yaml | grep -A6 managedFields
# representative — the HPA writes replicas via kube-controller-manager
  managedFields:
  - manager: kube-controller-manager
    operation: Update
    subresource: scale
    fieldsV1:
      f:spec:
        f:replicas: {}

The manager: kube-controller-manager and subresource: scale lines are your proof that managedFieldsManagers: [kube-controller-manager] is the correct, self-maintaining fix. In the UI, the same information lives in the app’s Diff tab (toggle “Compact diff” off to see full context) and the per-resource Manifest view, which shows Desired and Live side by side.


Hands-on lab

You will reproduce the perpetual-OutOfSync flap with a real HPA, then fix it three different ways and watch the app go stably Synced each time. This runs on a free local kind cluster — nothing here bills. Every command is copy-pasteable; the outputs shown are representative (labelled as such), not captured from a live run.

Prerequisites: kind, kubectl, argocd CLI, and a Git repo you can push to (any host). Replace https://github.com/YOU/diff-demo.git with yours throughout.

Step 1 — Cluster and Argo CD.

kind create cluster --name diff-lab
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl -n argocd rollout status deploy/argocd-server --timeout=180s
# log in (port-forward in another terminal: kubectl -n argocd port-forward svc/argocd-server 8080:443)
argocd login localhost:8080 --username admin \
  --password "$(kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d)" \
  --insecure

What just happened: a throwaway cluster with Argo CD installed and the CLI logged in.

Step 2 — metrics-server (so the HPA can actually scale).

kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
# kind's kubelet uses a self-signed cert; let metrics-server accept it (LAB ONLY — never in prod)
kubectl -n kube-system patch deploy metrics-server --type=json \
  -p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
kubectl -n kube-system rollout status deploy/metrics-server --timeout=120s

⚠️ --kubelet-insecure-tls disables kubelet certificate verification. It is fine for a local kind lab and never acceptable on a real cluster. If you’d rather skip metrics-server entirely, you can simulate the HPA in Step 5 with a manual kubectl scale — the diff Argo sees is byte-for-byte identical.

Step 3 — commit the desired state (replicas: 2). In your repo, create manifests/deploy.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: diff-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: registry.k8s.io/hpa-example   # burns CPU on demand
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: 100m
            limits:
              cpu: 200m

Commit and push. Then create the Argo app (manual sync policy for now, so we can watch each step deliberately):

argocd app create diff-demo \
  --repo https://github.com/YOU/diff-demo.git \
  --path manifests --revision main \
  --dest-server https://kubernetes.default.svc \
  --dest-namespace diff-demo \
  --sync-option CreateNamespace=true
argocd app sync diff-demo
argocd app get diff-demo
# representative: Sync Status: Synced   Health Status: Healthy

What just happened: the app is Synced/Healthy with two replicas — the clean starting point.

Step 4 — add the HPA and create the drift. Apply an HPA directly (it’s the “other controller” the platform owns, not part of your app’s Git):

kubectl apply -f - <<'EOF'
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: web
  namespace: diff-demo
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: web
  minReplicas: 2
  maxReplicas: 5
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 50
EOF

# drive CPU up so the HPA scales toward 5 (Ctrl-C after ~1 min; or just: kubectl -n diff-demo scale deploy/web --replicas=5)
kubectl -n diff-demo run load --image=busybox --restart=Never -- \
  /bin/sh -c "while true; do wget -q -O- http://web.diff-demo; done"

kubectl -n diff-demo get hpa web        # REPLICAS eventually shows 5
argocd app get diff-demo
# representative: Sync Status: OutOfSync   Health Status: Healthy
argocd app diff diff-demo
# representative
===== apps/Deployment diff-demo/web ======
5c5
<   replicas: 5      # live: the HPA scaled it up
---
>   replicas: 2      # desired: what Git says

What just happened: the HPA now owns replicas, so live (5) and Git (2) permanently disagree. The app is OutOfSync forever — and note it’s still Healthy, because the pods are fine; this is purely a diff problem. If you now enabled selfHeal, you’d watch it flap: Argo sets 2, the HPA scales back to 5, repeat.

Step 5 — Fix #1: ignoreDifferences with jsonPointers.

argocd app set diff-demo \
  --ignore-differences-jsonpointers /spec/replicas \
  --ignore-differences-group apps \
  --ignore-differences-kind Deployment
# (equivalently, add the ignoreDifferences block to the Application YAML and commit it)
argocd app get diff-demo
# representative: Sync Status: Synced   Health Status: Healthy   (stable — no flap)
argocd app diff diff-demo    # exits 0, no output

What just happened: the diff now drops /spec/replicas, so the app is stably Synced. Remember the nuance: the desired manifest still says replicas: 2, so a future sync would momentarily write 2 unless you also add RespectIgnoreDifferences=true.

Step 6 — Fix #2: managedFieldsManagers (the self-maintaining fix). First confirm who owns the field, then ignore that manager:

kubectl -n diff-demo get deploy web --show-managed-fields -o yaml | grep -A6 managedFields
# representative: manager: kube-controller-manager, subresource: scale, f:replicas: {}

# swap the field-level ignore for a manager-level one (edit the Application):
#   ignoreDifferences:
#     - group: apps
#       kind: Deployment
#       name: web
#       managedFieldsManagers: [kube-controller-manager]
argocd app get diff-demo
# representative: Sync Status: Synced   Health Status: Healthy

What just happened: instead of naming the field, you named the controller. If the HPA controller starts writing another field tomorrow, this rule already covers it — no maintenance.

Step 7 — Fix #3: ServerSideDiff=true.

argocd app set diff-demo \
  --sync-option ServerSideApply=true \
  --sync-option ServerSideDiff=true
argocd app diff diff-demo --server-side-generate
argocd app get diff-demo
# representative: Sync Status: Synced   Health Status: Healthy

What just happened: Argo now asks the API server to dry-run apply your manifest and diffs the result against live. For the HPA specifically you’ll typically still keep the managedFieldsManagers rule (the scale subresource is special), but Server-Side Diff is what you’d rely on for the webhook and defaulting cases — turn it on and a whole class of false diffs from injected sidecars and server defaults disappears with no per-field rules at all.

Step 8 — compare, then tear down.

Fix Lines of config Self-maintaining? Also fixes webhooks/defaults? Best when
jsonPointers: [/spec/replicas] 1 pointer No — pin per field No You need one exact field, or RespectIgnoreDifferences
managedFieldsManagers: [kube-controller-manager] 1 manager Yes No (covers that manager only) A named controller owns the fields
ServerSideDiff=true 1 sync option Yes Yes Meshes, Autopilot, defaulting webhooks — reach for it first
kubectl -n diff-demo delete pod load --ignore-not-found
argocd app delete diff-demo --yes
kind delete cluster --name diff-lab

What just happened: full teardown, zero lingering cost. You’ve now reproduced the most common Argo CD diff problem and fixed it three ways — and you can articulate the trade-offs between them.


Common mistakes and troubleshooting

Symptom Likely cause Fix
App perpetually OutOfSync on /spec/replicas, flaps with self-heal An HPA owns replicas; the diff flags it every reconcile ignoreDifferences on /spec/replicas, or managedFieldsManagers: [kube-controller-manager]
App OutOfSync showing a whole extra container A mesh mutating webhook injects a sidecar (istio-proxy) jqPathExpressions selecting the sidecar by name, or ServerSideDiff=true
OutOfSync on small fields you never wrote (protocol: TCP, imagePullPolicy) Server-side defaulting on create ServerSideDiff=true — the dry-run adds the same defaults to desired
Added ignoreDifferences but nothing changed The jsonPointers path is wrong — it matches no field and silently no-ops Verify with argocd app diff; check escaping (~1=/), array indices, exact spelling
jqPathExpressions throws or ignores nothing Invalid jq syntax, or the expression selects a value instead of a path Test the expression with the jq CLI against the live YAML first
Field ignored in the diff, but a sync still overwrites it ignoreDifferences affects the diff only, not the write Add RespectIgnoreDifferences=true (prefer jsonPointers/managedFieldsManagers)
App went Synced but a real change you made is now hidden Your ignore rule is too broad (whole /spec, or all kinds) Tighten the scope: exact group/kind/name + the narrowest field selector
Self-heal keeps fighting the HPA even after ignoreDifferences RespectIgnoreDifferences not set, so the sync still writes replicas Add RespectIgnoreDifferences=true, or use managedFieldsManagers + SSA
ServerSideDiff=true set but still OutOfSync Not actually enabled (typo in sync option), or controller not restarted after the global param Confirm the sync option on the app; for global, restart argocd-application-controller
A CRD is always OutOfSync on defaulted fields The CRD’s own defaulting/conversion webhook writes fields on admission ServerSideDiff=true, or ignoreDifferences on the defaulted paths
Quantities differ (500m vs 0.5, 1Gi vs bytes) on a CRD Argo doesn’t know the field is a resource quantity resource.customizations.knownTypeFields.<group>_<kind> in argocd-cm
Global argocd-cm rule ignored Wrong key shape — must be <group>_<kind> with an underscore (core group empty) Fix the key (e.g. apps_Deployment, _Secret); reload the ConfigMap

Three gotchas cost the most hours:

1. A wrong jsonPointers path fails silently. This is the cruelest one, because there’s no error — Argo simply finds nothing at that path and the diff is unchanged, so it looks like ignoreDifferences doesn’t work. Ninety percent of the time the path is misspelled, points at the wrong nesting level, or uses an array index that shifted. Always confirm the field name against argocd app diff output first, mind the RFC 6901 escaping (~1 for /), and remember that array elements are addressed by ordinal — which is exactly why jqPathExpressions (select-by-name) is safer for lists.

2. Ignoring a real drift you needed to see. ignoreDifferences is a loaded footgun when scoped too broadly. Ignoring all of /spec on a Deployment to silence the HPA also hides an accidental image downgrade, a removed env var, a broken resource limit — genuine drift GitOps exists to catch. Scope every rule to the narrowest thing that fixes the false positive: exact group/kind/name, and the single field (or single manager), never a whole subtree. If you find yourself ignoring big chunks of an object, the right tool is Server-Side Diff, not a wider ignore.

3. selfHeal: false as a “fix.” When an app flaps, the tempting one-liner is to disable self-heal. Don’t. That doesn’t fix the false diff — the app is still OutOfSync — and it disables drift correction for every field on the app, so a real kubectl edit in prod now goes uncorrected and unnoticed. The flap is a signal that the diff is wrong about ownership; fix the diff (Server-Side Diff, managed fields, or a scoped pointer) and leave self-heal doing its job on everything else.


Cheat-sheet

ignoreDifferences shapes (per-Application spec.ignoreDifferences[]):

Shape Use
group, kind Required matcher; core group is ""
name, namespace Optional — scope to one object
jsonPointers: [/spec/replicas] Ignore an exact field by RFC 6901 path
jqPathExpressions: ['... | select(.name=="x")'] Ignore an element selected by value/name
managedFieldsManagers: [kube-controller-manager] Ignore every field a named manager owns

Which selector to pick:

Situation Selector
One stable field (replicas) jsonPointers
Element in a list, known by name (sidecar) jqPathExpressions
Everything one controller writes managedFieldsManagers
Sync must also skip the field jsonPointers/managedFieldsManagers + RespectIgnoreDifferences=true
Webhooks, server defaults, Autopilot resources ServerSideDiff=true (not ignoreDifferences)

Server-Side Diff & options:

Item Value / effect
Per-app enable sync option ServerSideDiff=true (pair with ServerSideApply=true)
Fleet-wide enable controller.diff.server.side: "true" in argocd-cmd-params-cm
Opt one resource out annotation argocd.argoproj.io/compare-options: ServerSideDiff=false
Make sync respect ignores sync option RespectIgnoreDifferences=true

Global normalization in argocd-cm:

Key Effect
resource.customizations.ignoreDifferences.<group>_<kind> Global field-ignore for a kind
resource.customizations.ignoreDifferences.all Global field-ignore for every kind
resource.customizations.knownTypeFields.<group>_<kind> Semantic type so quantities compare equal
resource.compareoptions Global compare toggles (e.g. ignoreResourceStatusField)

Debugging commands:

Command Does
argocd app diff <app> Show live-vs-desired diff (exit 1 if any)
argocd app diff <app> --refresh Force a fresh compare first
argocd app diff <app> --local <dir> Diff live vs local manifests before committing
argocd app diff <app> --server-side-generate Diff using server-side-generated manifests
argocd app manifests <app> Print the rendered desired manifests
kubectl get <kind> <name> --show-managed-fields -o yaml Find the field-manager (→ managedFieldsManagers)

Interview and exam questions

Q: An app is stuck OutOfSync and re-syncing doesn’t help. Walk me through your diagnosis. A: Run argocd app diff <app> to see the exact field(s) that differ. If a field is present in live but not desired, some controller owns it. Identify the actor (HPA → /spec/replicas; mesh webhook → a sidecar container; Autopilot → resources) and confirm the owner with kubectl get ... --show-managed-fields. Then apply the narrowest fix: Server-Side Diff for webhooks/defaults, managedFieldsManagers for a whole controller, or a scoped jsonPointers for one field. Never selfHeal: false.

Q: Why does an HPA cause perpetual OutOfSync, and what exactly happens if self-heal is on? A: The HPA writes spec.replicas on the Deployment, but Git still says the original count, so live and desired permanently disagree on that field — OutOfSync forever. With self-heal on, Argo reverts replicas to the Git value, the HPA immediately re-scales to its computed value, and the two controllers flap every reconcile interval. The fix is to remove replicas from the diff (ignore the field or the kube-controller-manager field-manager).

Q: What’s the difference between jsonPointers, jqPathExpressions, and managedFieldsManagers? A: jsonPointers selects an exact field by RFC 6901 path — best for a single stable field like /spec/replicas. jqPathExpressions selects by a jq query, so you can pick a list element by value (the container named istio-proxy) rather than by fragile index. managedFieldsManagers ignores every field owned by a named field-manager — the SSA-era approach, best when a whole controller’s writes should be ignored and self-maintaining as that controller evolves.

Q: Does ignoreDifferences stop a sync from writing the ignored field? A: No. By default ignoreDifferences only affects the diff (whether the app is OutOfSync). The desired manifest still contains the field, so a sync will write it. To make the sync also leave the field alone, add the RespectIgnoreDifferences=true sync option — and prefer jsonPointers/managedFieldsManagers, since jq support for it has been limited.

Q: What is Server-Side Diff and why is it usually the better first move than ignoreDifferences? A: With ServerSideDiff=true, Argo asks the API server to perform a dry-run Server-Side Apply of the desired manifest and diffs the result against live. Because the dry-run runs defaulting and mutating webhooks and respects other field-managers, injected sidecars and server defaults appear on both sides and simply match — removing whole categories of false diffs with one flag and no per-field list to maintain. You reach for it first because it’s self-maintaining; ignoreDifferences is the surgical fallback for what SSD doesn’t cover (notably HPA replicas).

Q: You add ignoreDifferences on /spec/replicas but the diff is unchanged. What went wrong? A: Almost certainly the JSON Pointer doesn’t match — a typo, the wrong nesting, or a bad array index. A wrong pointer fails silently (it matches no field). Confirm the field name against argocd app diff, check RFC 6901 escaping (~1 = /), and if it’s a list element selected by name, switch to jqPathExpressions.

Q: How do you fix the same false diff across 200 apps without editing each Application? A: Put a global rule in argocd-cm under resource.customizations.ignoreDifferences.<group>_<kind> (e.g. apps_Deployment) — same shape as the per-app list. Or enable Server-Side Diff fleet-wide via controller.diff.server.side: "true" in argocd-cmd-params-cm. Both avoid per-app duplication.

Q: A CRD is always OutOfSync because 500m in Git shows as 0.5 in live. Fix it. A: Argo doesn’t know the field is a resource quantity, so it compares the strings. Declare the field’s known type via resource.customizations.knownTypeFields.<group>_<kind> in argocd-cm (type core/v1/ResourceList), and the normalizer will parse both as quantities and compare them equal.

Q: Why is selfHeal: false the wrong way to stop a flap? A: It doesn’t fix the diff — the app is still OutOfSync — and it disables drift correction for the entire app, so a real out-of-band change in prod goes silently uncorrected. The flap means the diff is wrong about which fields Argo owns; fix the diff (Server-Side Diff / managed fields / scoped pointer) and keep self-heal protecting every other field.

Q: How do you find which field-manager owns a drifting field? A: kubectl get <kind> <name> --show-managed-fields -o yaml and read metadata.managedFields. Each entry lists the manager, the operation, and (for the HPA) subresource: scale with f:spec: f:replicas: {}. The manager name is exactly what you put in managedFieldsManagers.

Q: On EKS, a Deployment with an IRSA/Pod-Identity service account is OutOfSync on env vars you never set. Why, and the cleanest fix? A: The EKS pod-identity-webhook mutating admission webhook injects AWS_* env vars and a projected token volume into pods using that service account. Those live fields aren’t in Git, so the naive diff flags them. Cleanest fix: ServerSideDiff=true, so the dry-run runs the same webhook and the injected fields match on both sides. (The AKS Workload Identity webhook and GKE Autopilot resource-defaulting cause the identical class of problem, fixed the same way.)


Key takeaways

argocdgitopskubernetesignoredifferencesserver-side-diffdriftoutofsynchpamutating-webhookserver-side-applyakseksgketroubleshooting
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments