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 targetRevision — helm 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.
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-tlsdisables kubelet certificate verification. It is fine for a localkindlab 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 manualkubectl 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
- The diff compares rendered desired (your Git fields only) against normalized live (your fields plus server defaults, other controllers’ fields, and metadata). False positives come from live-only fields Argo can’t guess it should ignore.
- Perpetual OutOfSync means another actor owns a field you never set — an HPA on
replicas, a webhook injecting a sidecar, a defaulting controller, cert-manager. Identify the field and the actor withargocd app diffand--show-managed-fields. ignoreDifferencesaffects the diff, not the sync. AddRespectIgnoreDifferences=truewhen the sync itself must leave a field alone — and it’s separate frompruneandselfHeal.- Pick the selector to fit the problem:
jsonPointersfor one exact field,jqPathExpressionsto select a list element by name,managedFieldsManagersto ignore a whole controller’s fields (self-maintaining). - Reach for Server-Side Diff first.
ServerSideDiff=truemakes the API server account for defaults and webhooks via a dry-run apply, erasing whole categories ofignoreDifferenceshacks — especially injected sidecars, server defaults, and GKE Autopilot resource mutation. - The cloud edge is the set of mutating webhooks, not the diff itself: AKS Workload Identity, EKS Pod Identity/IRSA, and GKE Autopilot each inject fields — and Server-Side Diff handles all three the same way.
- Never use
selfHeal: falseto silence a flap. It hides all drift; it doesn’t fix the ownership boundary. Fix the diff and keep self-heal working on every other field.