In a nutshell
Part 1 of this pair was about reading a status board — is the app OutOfSync, Degraded, Unknown? This lesson is about the moments when Argo CD is actively rearranging your cluster and something goes wrong in the middle of the move. Those are the failures that turn into incident channels, because a sync does not merely report state — it changes it.
Picture a warehouse robot that follows a floor-plan (your Git repo) to arrange the shelves. Most of the time it hums along. But sometimes it drops a box halfway — the shelf refused the item (a SyncFailed); or it freezes in the middle of an aisle and blocks every other robot (a stuck operation, phase Running forever); or — the one nobody forgets — the new floor-plan no longer lists a box, so the robot dutifully throws it out (a prune). And here is the truth that governs the whole lesson: the floor-plan can tell the robot to put an empty box back on the shelf, but it can never refill what was inside. Git restores your configuration; it does not restore your data.
By the end you can dissect a SyncFailed down to the exact apiserver message, cancel a hung operation, safely clear a resource stuck Terminating, and — most importantly — prevent and recover from a prune that deleted a PVC, a Namespace, or a CRD.
Level: Advanced · Time: ~63 min
Prerequisites: finish Troubleshooting Part 1 (reading status, health and the sync comparison), and be comfortable with Sync Waves & Resource Hooks and Sync Policies: Automated, Self-Heal & Prune. After this you can: read operationState to triage any sync, fix the common SyncFailed messages, terminate-op a hang, clear a finalizer without orphaning a cloud disk, and work a prune prevention-and-recovery runbook.
There are two kinds of Argo CD problem. The first kind is loud but harmless: an app sits OutOfSync, a health check reads Degraded, a repo shows Unknown. You lose sleep but not data, and Troubleshooting Argo CD Part 1 is the field guide for those — reading the status, the comparison, the health assessment. This lesson is the second kind: the failures that happen while Argo CD is actively changing your cluster, and the ones where the recovery move itself can make things worse. A sync that fails halfway. A migration Job that takes the whole deploy down. An operation that hangs on Running and will not let go. A resource stuck Terminating behind a finalizer nobody can explain. And the one every platform engineer eventually meets and never forgets: a manifest removed from Git, prune enabled, and a production PersistentVolumeClaim — or an entire namespace — gone.
The mental model that ties all of this together is one sentence: a sync is an operation with a lifecycle, not an instant. When you press Sync, Argo CD starts an operation that moves through phases, applies resources in waves, runs your hooks, prunes what you deleted, and only then settles. Every failure here is a specific place that operation can break — loudly (a SyncFailed), silently (a hang on Running), or catastrophically (a prune) — and every recovery is a specific move: read the sync result, terminate-op the hang, patch away a finalizer, or re-assert Git. The ugliest truth of GitOps disaster recovery threads through all of them: Git holds your desired state, not your data. Re-syncing a pruned PVC gives you a brand-new empty volume, not the bytes back — and knowing exactly where that line falls separates a five-minute recovery from an unrecoverable outage.
Everything here targets Argo CD 2.13+ / 3.x on Kubernetes 1.29+. Argo CD itself is cloud-neutral, but the blast radius of a prune is not — a deleted PVC hits a different disk on AKS, EKS and GKE, and a deleted Service of type LoadBalancer deprovisions a real, billable cloud load balancer. Where the edge is cloud-specific, all three clouds get a column.
Why this matters
The failures in Part 1 cost you a red icon in the UI. The failures in this lesson cost you an incident channel. The difference is that a sync operation mutates the live cluster — it creates, patches, and deletes real objects — so when it goes wrong it does not just misreport state, it changes state, sometimes irreversibly. A prune that fires against the wrong path does not show you a warning icon; it issues a DELETE to the apiserver, the apiserver honours it, and if that object was a dynamically-provisioned PVC on a Delete reclaim policy, the CSI driver tears down the underlying cloud disk seconds later. There is no icon to un-click.
The place learners hit this is predictably the worst possible moment: a routine refactor. Someone moves a directory, renames an app, or merges a branch that dropped a file, and an automated Application with prune: true faithfully deletes everything that “disappeared” from Git — because from Argo CD’s point of view, you told it to. The same evening, a PreSync database migration that passed in staging fails in prod on a subtly different dataset, the sync halts at wave 1, and the new pods never roll. Or a sync simply stops — phase Running, no error, no progress — because a hook is waiting on something that will never happen, and nothing else in that app can sync until you cancel the operation by hand.
The mental model to carry through every section: read the operation, then decide the move. Argo CD records exactly what it tried, on which resource, and what the apiserver said back, in the operationState of the Application. Most of the panic in these incidents comes from not reading that record — re-syncing blindly or force-deleting something turns a recoverable problem into a data-loss one. The engineers who stay calm run argocd app get first, read the failed resource and its message, and only then act, because the message almost always names both the problem and the fix.
The anatomy of a sync operation
Before any specific failure, you need the state machine. When a sync starts — manually, by automation, or by a webhook — Argo CD creates an operation and tracks it in status.operationState on the Application. That operation has a phase, and the phase is the single most important field you will read all lesson:
Operation phase |
Meaning | What you do |
|---|---|---|
Running |
The operation is in progress — applying resources, waiting on waves/hooks | Wait; if it never leaves Running, it is stuck (see terminate) |
Succeeded |
All resources applied, all waves healthy, all hooks passed | Nothing — this is the goal |
Failed |
A resource or hook failed to apply/complete; the operation stopped | Read the failed resource + message, fix, re-sync |
Error |
Argo CD itself errored running the operation (not a resource error) | Often transient/infra; read controller logs, retry |
Terminating |
Someone cancelled the operation (terminate-op) and it is winding down |
Wait for it to clear, then diagnose and re-sync |
Do not confuse the operation phase with the two statuses you learned in Part 1. They answer different questions, and reading the wrong one sends you down the wrong path:
| Field | Question it answers | Example values |
|---|---|---|
operationState.phase |
Did the last sync attempt succeed? | Running, Succeeded, Failed, Error, Terminating |
Sync status |
Does live match Git right now? | Synced, OutOfSync, Unknown |
Health status |
Are the live resources actually working? | Healthy, Progressing, Degraded, Suspended, Missing |
| Resource-level sync | Did this one resource apply? | Synced, OutOfSync, SyncFailed |
Inside a running or finished operation, Argo CD records a sync result — a per-resource list of what it tried and what happened. This is where a SyncFailed lives, and reading it is the core skill of this lesson. You surface it with argocd app get:
# The operation, the result, and the per-resource status in one view
argocd app get payments --show-operation
# ---- representative output (abridged, formatted) ----
Name: argocd/payments
Sync Status: OutOfSync from main (a1b2c3d)
Health Status: Missing
Operation: Sync
Sync Revision: a1b2c3d...
Phase: Failed
Message: one or more objects failed to apply, reason: Deployment.apps
"payments" is invalid: spec.selector: Invalid value:
field is immutable
GROUP KIND NAMESPACE NAME STATUS HOOK MESSAGE
apps Deployment payments payments SyncFailed field is immutable
"" Service payments payments Synced
"" ConfigMap payments payments Synced
That block tells you everything: the operation Phase: Failed, the top-level Message, and crucially the one resource whose STATUS is SyncFailed with its own MESSAGE. Two resources synced fine; one — the Deployment — was rejected. You now know precisely what to fix. The equivalent raw view, useful in scripts and when the CLI truncates, is the operationState in the object itself:
# Pull the machine-readable operation state (great for jq and CI gates)
kubectl -n argocd get application payments -o jsonpath='{.status.operationState.phase}'
# Failed
# The full per-resource sync result
kubectl -n argocd get application payments \
-o jsonpath='{.status.operationState.syncResult.resources[*].message}'
Each entry in that sync result carries the fields you read, filter, and alert on:
syncResult.resources[] field |
Meaning |
|---|---|
group / kind / namespace / name |
Which object this row describes |
status |
Synced, SyncFailed, OutOfSync, PruneSkipped |
message |
The verbatim apiserver reason — the fix is usually right here |
hookPhase |
For hooks: Running, Succeeded, Failed |
syncPhase |
PreSync, Sync, PostSync, SyncFail |
The diagram below is the whole lesson on one page: the sync operation on the left, the three ways it breaks in the middle (a loud failure, a hang, or the red prune disaster), the recovery move for each, and the sober truth on the right that recovering the object is not the same as recovering the data.
The badges mark the decisions that matter: a failed hook fails the whole sync (1); a stuck op hangs on Running with no error and needs terminate-op (2); prune is the irreversible one (3); a Terminating resource is waiting on a finalizer (4); recovery almost always starts by re-asserting Git (5); and recreating an object is not recovering its data (6). Hold that last one in your teeth — it is the difference between a config outage and a data outage.
SyncFailed: reading the failed resource and its message
SyncFailed is a resource-level status: Argo CD sent the object to the apiserver (or to server-side apply) and the apiserver rejected it. This is distinct from OutOfSync (live differs from Git, but nothing was attempted) and from ComparisonError (Argo could not even compute the diff — repo unreachable, template render failure — covered in Part 1). SyncFailed means the attempt happened and was refused, and the refusal reason is almost always a verbatim apiserver error that names the fix.
Your entire diagnostic loop is: find the resource with STATUS: SyncFailed, read its MESSAGE, and match it to a cause. Here is the table you will come back to. Every message in it is a real apiserver or Argo CD string, lightly trimmed:
SyncFailed message (representative) |
Root cause | Fix |
|---|---|---|
field is immutable / spec.selector: Invalid value: ...: field is immutable |
You changed a field Kubernetes forbids updating in place | Add Replace=true sync option, or delete + re-sync the resource ⚠️ |
Service "x" is invalid: spec.clusterIP: Invalid value: ...: may not change once set |
Same class — clusterIP is immutable |
Remove clusterIP from the manifest, or Replace=true |
application destination server ... is not permitted in project 'X' |
The AppProject does not allow this destination cluster/namespace |
Widen the project’s destinations, or fix the app’s destination |
Resource :ClusterRole is not permitted in project X |
clusterResourceWhitelist / namespaceResourceBlacklist denies this kind |
Add the kind to the project whitelist (deliberately), or drop the resource |
admission webhook "validate.kyverno.svc" denied the request: ... |
A validating/mutating webhook (Kyverno, Gatekeeper, OPA) rejected the object | Fix the manifest to satisfy the policy, or amend the policy |
Internal error occurred: failed calling webhook ...: context deadline exceeded |
The webhook backend is down or unreachable, so the apiserver fails closed | Restore the webhook service; consider failurePolicy review |
namespaces "foo" not found |
Target namespace does not exist and Argo was not told to create it | Add CreateNamespace=true, or a Namespace manifest in an earlier wave |
unable to recognize "...": no matches for kind "Rollout" in version "argoproj.io/v1alpha1" |
The CRD is missing (or in the same sync, not yet registered) | Install CRD first (earlier wave) or add SkipDryRunOnMissingResource=true |
... is forbidden: User "system:serviceaccount:argocd:..." cannot create resource ... |
The credentials for the target cluster lack RBAC for this kind | Grant the Argo CD service account/role the missing RBAC on that cluster |
error validating data: ValidationError(Deployment.spec): unknown field ... |
Schema/dry-run validation failed — a typo’d or unknown field | Fix the manifest; only use Validate=false if the field is a known CRD gap |
The order in patch list ... doesn't match $setElementOrder |
Strategic-merge patch conflict on a list (often env vars/ports) | Switch to ServerSideApply=true, or clean the list ordering |
metadata.annotations: Too long: must have at most 262144 bytes |
The last-applied-configuration annotation blew the size limit |
Use ServerSideApply=true (no giant annotation) |
Several of those fixes are sync options — flags that change how Argo CD applies a resource, set per-app under syncPolicy.syncOptions or per-resource via the argocd.argoproj.io/sync-options annotation. They are the single most useful lever for apply-time failures:
| Sync option | What it changes | Fixes |
|---|---|---|
Replace=true |
kubectl replace (delete+recreate) instead of apply/patch |
Immutable-field rejections (⚠️ not on stateful data) |
ServerSideApply=true |
Server-side apply instead of client-side | List-merge conflicts, the 262144-byte annotation limit |
CreateNamespace=true |
Creates the destination namespace if absent | namespaces "x" not found |
SkipDryRunOnMissingResource=true |
Skips client dry-run when the CRD isn’t registered yet | no matches for kind when CRD + CR are in one sync |
Validate=false |
Disables kubectl schema validation |
A known CRD schema gap (use sparingly) |
PruneLast=true |
Prunes only after all applies succeed and are healthy | Ordering bugs that delete a still-referenced resource |
Prune=false |
Never prunes this resource | Protecting crown-jewel PVCs/namespaces |
Three of the failure causes deserve real attention because they are the ones that recur and the ones where the fix has teeth.
The immutable-field failure. Certain fields are fixed at creation and Kubernetes will reject an in-place update forever. The usual suspects: a Deployment/StatefulSet/Job spec.selector, a Service spec.clusterIP, a PersistentVolumeClaim’s storageClassName or a shrink of its size, and most of a StatefulSet’s spec beyond replicas/template/updateStrategy. Argo CD applies with a patch by default, the apiserver says field is immutable, and the resource sits SyncFailed on every sync. There are exactly two ways forward, and the safe one depends entirely on what the resource is:
| Field changed | What Replace=true does |
Data-loss risk |
|---|---|---|
Deployment.spec.selector |
Deletes + recreates the Deployment; pods reschedule | Low — brief downtime, no persistent data |
Service.spec.clusterIP |
Recreates the Service; new ClusterIP | Low — but the VIP changes (DNS/clients re-resolve) |
StatefulSet.spec (immutable parts) |
Deletes + recreates the StatefulSet | ⚠️ Medium — verify PVCs are retained, not orphaned |
PersistentVolumeClaim size/class |
Deletes + recreates the PVC | ⚠️ HIGH — a recreated PVC binds a NEW empty volume |
Set Replace=true per-app or, more safely, scoped to exactly the object that needs it:
# Per-resource opt-in — safest, scoped to exactly the object that needs it
metadata:
annotations:
argocd.argoproj.io/sync-options: Replace=true
⚠️ Never blanket
Replace=trueon an app containing aPersistentVolumeClaimor a data-bearingStatefulSet.Replacedeletes then recreates, and a recreated PVC provisions a brand-new empty volume — “fixing” an immutable-field error with a blindReplaceon stateful storage is how you turn a red icon into a restore-from-backup. For stateful objects, change the resource’s name instead (create new, migrate, delete the old deliberately), or plan a maintenance window.
The project-denied failure. Argo CD’s AppProject is a hard boundary. If a project’s destinations, clusterResourceWhitelist, or namespaceResourceWhitelist does not permit what the app is syncing, the sync fails before touching the cluster with a not permitted in project message — the project stopped a fat-fingered or compromised app from escaping its lane. This is a policy failure, not a Kubernetes one: no edit to the manifest will satisfy it. You fix it deliberately in the AppProject (widen destinations/whitelists) or by correcting the app’s destination — never by reflexively granting '*'.
The webhook-rejected failure. When a validating/mutating admission webhook (Kyverno, Gatekeeper/OPA, a mesh injector, cert-manager) rejects an apply, the message is a verbatim admission webhook "..." denied the request: <reason> — a policy telling you no, so read the reason and satisfy it. The nastier cousin, failed calling webhook ...: context deadline exceeded, means the webhook backend is down and the apiserver is failing closed; Argo CD stays SyncFailed until the webhook service is healthy again. The fix is to restore the webhook (or review its failurePolicy/namespaceSelector if it chronically blocks a namespace Argo owns), not to keep re-syncing.
Hook failures: when a PreSync Job takes the whole sync down with it
Resource hooks — covered in depth in Sync Waves & Resource Hooks — let you run a resource (almost always a Job) at a defined point in the sync. The classic is a PreSync database migration that must complete before the new pods roll. That coupling is exactly what makes hooks a failure amplifier: a failed PreSync hook fails the entire sync, and the Sync and PostSync phases never run. Your new version does not deploy, not because the Deployment was wrong, but because a migration Job exited non-zero.
The hook lifecycle maps onto the operation like this:
| Hook phase | Runs when | If it fails |
|---|---|---|
PreSync |
Before the main sync wave | Operation → Failed; Sync + PostSync skipped |
Sync |
Alongside the main resources (respects waves) | Operation → Failed; PostSync skipped |
PostSync |
After all resources are Healthy | Operation → Failed; app is deployed but post-checks failed |
SyncFail |
Only when the sync operation fails | Runs your cleanup/rollback logic |
Skip |
Never applied (marker to exclude a resource) | n/a |
And the delete policy governs what happens to the hook’s Job afterward — this is where a “why do I have 40 completed Jobs?” surprise comes from:
hook-delete-policy |
The hook Job is deleted… | Use it for |
|---|---|---|
BeforeHookCreation (default) |
Right before the next sync creates a new one | Keeping the last run visible for debugging |
HookSucceeded |
Immediately after it succeeds | Migrations you never need to inspect on success |
HookFailed |
Immediately after it fails | Rare — you usually want the failed Job kept |
Four hook failure modes cause almost every incident:
| Symptom | Cause | Fix |
|---|---|---|
Sync Failed, PreSync Job Failed, new pods never rolled |
Migration Job exited non-zero (bad SQL, missing grant, data mismatch) | Read kubectl logs job/<hook>, fix, argocd app sync --retry |
Sync stuck Running for an hour, Job still Active |
Hook never completes — waits on a lock, a dependency, an interactive prompt | argocd app terminate-op, fix the Job, re-sync |
Dozens of Completed hook Jobs piled up |
Hook uses generateName (unique name each run) so BeforeHookCreation can’t GC it |
Set hook-delete-policy: HookSucceeded, or use a stable name |
| Re-running the sync corrupts data | Migration is not idempotent — re-applies an already-applied change | Make migrations idempotent (guard with IF NOT EXISTS / versioned tools) |
The non-idempotent migration is the subtle killer. Argo CD may retry a hook, and you will certainly re-sync after a partial failure. If the Job does ALTER TABLE ... ADD COLUMN with no existence guard, the first run succeeds; a later unrelated failure makes you re-sync, and the second run errors on “column already exists” — or worse, a data-mutating step runs twice. In a reconcile loop your hook will run more than once, so a GitOps-correct migration is idempotent by construction: a versioned tool that tracks applied versions, or guards like ADD COLUMN IF NOT EXISTS.
When a hook wedges, the operation message reads waiting for completion of hook batch/Job/db-migrate and the Job sits Running 0/1 for minutes — the signature of a sync gated on a hook (Drill 1 in the lab walks the exact diagnosis). The smoking gun is always in kubectl logs job/<hook>; once the logs tell you why it is stuck, cancel the operation (next section) and fix the root cause. Re-syncing on top of a stuck operation does nothing — Argo CD will not start a new operation while one is Running.
Stuck operations: terminating ops, finalizers, and things that will not die
A stuck operation is a sync whose phase stays Running with no forward progress: a hook that never completes, a wave-0 resource that never reaches Healthy, or a rare controller wedge. The tell is always the same — Phase: Running, an unchanging Message, and minutes or hours on the clock. Because Argo CD runs one operation per app at a time, a stuck op blocks every other change to that app. You cannot sync past it; you have to cancel it.
Terminating a running operation
argocd app terminate-op cancels the in-flight operation. It does not roll anything back or delete anything — it stops Argo CD waiting, sets the phase to Terminating, and lets the operation wind down so you can diagnose and re-sync cleanly.
# Cancel the stuck operation
argocd app terminate-op payments
# representative: application 'payments' operation terminating
# Confirm it cleared
argocd app get payments --show-operation | grep Phase
# Phase: Terminating -> then the operation clears and you can sync again
| Way to terminate | How | When to reach for it |
|---|---|---|
| CLI | argocd app terminate-op <app> |
Scripts, on-call, the default |
| UI | App view → the running sync’s Terminate button | When you are already looking at it |
kubectl (last resort) |
Patch .operation off the Application ⚠️ |
Only if the API server is fine but the CLI/UI cannot reach it |
⚠️ The
kubectlroute — removing the.operationfield from theApplicationwith a patch — is a genuine last resort. It bypasses Argo CD’s own cleanup, and if the controller is mid-apply you can leave partial state behind. Preferterminate-op; use the patch only when the API server is healthy but Argo’s own endpoints are not, and expect to runargocd app getafterward to reconcile what actually landed.
After terminating, you are back to a diagnosable state. Read the resource that was blocking (the hook Job, the unhealthy wave-0 pod), fix it, and argocd app sync again. The terminate is never the fix — it is what lets you fix.
A resource stuck Terminating
Different failure, often confused with the above. Here the sync is fine but a resource you tried to delete (via prune, or because you removed the app) hangs in Terminating state forever. Kubernetes will not remove an object while it still has finalizers — entries under metadata.finalizers that a controller is supposed to clear once its cleanup is done. If that controller is gone, broken, or blocked, the finalizer never clears and the object is wedged.
# A PVC that will not die
kubectl -n payments get pvc data-postgres-0
# NAME STATUS VOLUME CAPACITY AGE
# data-postgres-0 Terminating pvc-... 20Gi 9d
# WHY it is stuck — read the finalizers before you touch anything
kubectl -n payments get pvc data-postgres-0 -o jsonpath='{.metadata.finalizers}'
# ["kubernetes.io/pvc-protection"]
The common finalizers and what they protect:
| Finalizer | On | It is waiting for | Force-removing it means |
|---|---|---|---|
kubernetes.io/pvc-protection |
PVC | No pod is still mounting the PVC | You may detach a volume a running pod uses ⚠️ |
kubernetes.io/pv-protection |
PV | The bound PVC is gone | Usually safe once the PVC is truly gone |
finalizers.kubernetes.io/... (namespace spec.finalizers: [kubernetes]) |
Namespace | All child API objects deleted | You orphan whatever could not be deleted ⚠️ |
resources-finalizer.argocd.argoproj.io |
Application | Argo to cascade-delete managed resources | Deleting the app orphans its workloads (not always bad) |
Controller-specific (e.g. <db-operator>/finalizer) |
A custom resource | The operator to run teardown (snapshot, deregister) | You skip real cleanup — leaked cloud objects ⚠️ |
The safe procedure is diagnose first, patch last. A PVC stuck on pvc-protection is usually waiting because a pod still mounts it — the correct fix is to delete the pod, not the finalizer. Only when you have confirmed the protecting condition is genuinely satisfied (or the controller is gone for good) do you force-clear:
# LAST RESORT — force-remove finalizers so Kubernetes can delete the object
kubectl -n payments patch pvc data-postgres-0 \
--type=merge -p '{"metadata":{"finalizers":null}}'
⚠️ Force-clearing a finalizer tells Kubernetes “the cleanup this finalizer represents is done” — whether or not it is. On a PVC still mounted by a pod, you can force a detach and corrupt a filesystem; on a namespace, you orphan every child the controllers could not delete; on an operator-managed resource, you skip teardown and leak the cloud thing it manages (a disk, a load balancer, a DNS record that keeps billing). Never patch a finalizer to
nulluntil you have read what it protects and confirmed that protection is no longer needed.
The stuck-namespace variant is common enough to name. A Namespace in Terminating is blocked because at least one API object in it could not be finalized. The right fix is to find and delete that object; the force route uses the finalize subresource, and it orphans anything left behind:
# Find what is still in the terminating namespace (the real fix targets these)
kubectl api-resources --verbs=list --namespaced -o name \
| xargs -n1 kubectl get --show-kind --ignore-not-found -n stuck-ns
# Force route (orphans stragglers) — clear spec.finalizers via the finalize API ⚠️
kubectl get namespace stuck-ns -o json \
| jq '.spec.finalizers=[]' \
| kubectl replace --raw /api/v1/namespaces/stuck-ns/finalize -f -
A Rollout paused (not stuck — waiting)
One “stuck” report is not a failure at all. If you use Argo Rollouts, a canary that reaches a pause: {} step with no duration stops and waits for a human. Argo CD’s health check for a paused Rollout returns Suspended, the app may read Progressing/Suspended, and the sync will not complete the wave until you promote it. This is by design, not a wedge. The fix is not terminate-op; it is a decision:
| Rollout status | Argo CD health | Your move |
|---|---|---|
Progressing |
Progressing |
Wait — the canary is advancing through its steps |
Paused |
Suspended |
Decide: promote to continue, or abort to roll back |
Degraded |
Degraded |
Analysis failed — investigate, likely abort |
Healthy |
Healthy |
Done — fully promoted to stable |
# It is paused on purpose, waiting for you to promote (or abort)
kubectl argo rollouts get rollout payments -n payments # STATUS: Paused
kubectl argo rollouts promote payments -n payments # continue the canary
# or: kubectl argo rollouts abort payments -n payments # roll back to stable
Pruning disasters: the section people need most
This is the one. Everything above costs you time; a bad prune can cost you data. Prune means: when a resource that existed in Git disappears from it, Argo CD deletes it from the cluster — the correct, desirable behaviour of GitOps, since Git is the source of truth. The disaster is that Argo CD cannot tell “the human deliberately deleted this manifest” from “a refactor moved a directory, a merge dropped a file, or targetRevision now points where the file does not exist.” Either way the resource vanished from desired state, so it prunes it — and if that was a production PersistentVolumeClaim, Namespace, or CustomResourceDefinition, you now have an incident.
The trigger is almost never “someone deleted a PVC manifest on purpose”:
| How the resource “disappeared” from Git | The prune that follows |
|---|---|
| A directory was renamed/moved during a refactor | Every resource under the old path is pruned |
| A bad merge dropped a file | That resource is pruned |
targetRevision changed to a branch/tag where the file is absent |
Everything only-present-on-the-old-revision is pruned |
| A Kustomize/Helm change stopped rendering a resource | The no-longer-rendered resource is pruned |
An ApplicationSet generator produced fewer results |
Whole apps are deleted, cascading to their resources |
A wrong path typo points at an near-empty directory |
Almost everything is pruned as “removed” |
Blast radius: why a namespace or a PVC is the nightmare
Not all prunes are equal. Pruning a Deployment is a shrug — re-sync from Git and the pods come back. Pruning stateful or container resources is where data dies:
| Pruned resource | Immediate effect | Recoverable? |
|---|---|---|
Deployment / Service / ConfigMap |
Pods stop / VIP gone / config gone | ✅ Yes — re-sync from Git |
PersistentVolumeClaim |
PV unbinds; cloud disk deleted if reclaim=Delete | ⚠️ Data only from a snapshot/backup |
Namespace |
Cascading delete of EVERYTHING in it | ⚠️ Catastrophic — PVCs, LBs, secrets, all of it |
CustomResourceDefinition |
Cascading delete of every CR of that kind | ⚠️ Catastrophic — deletes all instances cluster-wide |
Secret (e.g. a TLS cert, a DB credential) |
Apps lose auth; cert-manager may re-issue | Partial — re-issue or restore from a secret store |
Service type LoadBalancer |
The cloud load balancer is deprovisioned | ✅ Recreated on re-sync — but the public IP changes |
Two are five-alarm fires. A pruned Namespace triggers Kubernetes’ cascading delete — the apiserver removes every object in it: Deployments, StatefulSets, Services (deprovisioning cloud LBs), and PVCs (destroying cloud disks on a Delete reclaim policy). One pruned namespace wipes an application’s entire state in seconds. A pruned CRD is worse in a different way: it cascades to every custom resource of that kind cluster-wide, so pruning one CRD can delete every Certificate, every Rollout, or every database an operator manages — regardless of namespace or app.
The cloud-specific truth: your PV reclaim policy decides if data survives
This is the reality that turns a recoverable prune into an unrecoverable one — and it differs by cloud only in the name of the disk that dies. A dynamically-provisioned PV inherits its reclaimPolicy from its StorageClass, and the default StorageClass on all three clouds uses reclaimPolicy: Delete. So deleting the PVC deletes the PV, which tells the CSI driver to delete the cloud disk, which destroys the bytes:
| Cloud | Default StorageClass | CSI driver | Default reclaimPolicy |
What dies when the PVC is pruned |
|---|---|---|---|---|
| AKS | managed-csi / managed-csi-premium |
Azure Disk CSI (disk.csi.azure.com) |
Delete |
The Azure Managed Disk is deleted |
| EKS | gp2 (legacy) / gp3 via EBS CSI |
EBS CSI (ebs.csi.aws.com) |
Delete |
The EBS volume is deleted |
| GKE | standard-rwo / premium-rwo |
PD CSI (pd.csi.storage.gke.io) |
Delete |
The Persistent Disk is deleted |
The prevention is one line, set before you ever need it, on any StorageClass backing stateful data:
# A StorageClass whose volumes SURVIVE a PVC deletion (data outlives the claim)
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: retained-ssd
provisioner: disk.csi.azure.com # EKS: ebs.csi.aws.com · GKE: pd.csi.storage.gke.io
reclaimPolicy: Retain # <-- the PV (and cloud disk) is kept, not deleted
volumeBindingMode: WaitForFirstConsumer
The three policy values and what each does to your data:
reclaimPolicy |
On PVC delete, the PV… | Data |
|---|---|---|
Delete (the cloud default) |
is deleted; the CSI driver deletes the cloud disk | ⚠️ Gone |
Retain |
is kept as Released; the cloud disk is preserved |
✅ Recoverable (manual re-bind) |
Recycle (deprecated) |
is scrubbed and made Available |
n/a — removed from Kubernetes |
reclaimPolicy: Retain is the single most important pre-disaster setting for stateful workloads under GitOps: a pruned PVC leaves an orphaned but intact PV and cloud disk you can re-bind a new PVC to manually.
Prevention: five guardrails, in order of strength
You prevent prune disasters with layered defence, not one setting. From “protects a single crown-jewel resource” to “protects a whole environment”:
| Guardrail | Scope | What it does |
|---|---|---|
Prune=false annotation |
One resource | Argo CD never prunes this object even if it leaves Git |
reclaimPolicy: Retain |
One StorageClass | The cloud disk survives even if the PVC is pruned |
syncPolicy.syncOptions: PruneLast=true |
One app | Prunes only after everything else applied and is healthy |
Sync windows (deny) on the AppProject |
Many apps | Freezes syncs (and prunes) during protected hours |
argocd app diff / --dry-run before sync |
Every manual sync | Shows you what would be pruned before it happens |
The per-resource opt-out is your seatbelt for anything stateful:
# This PVC will never be pruned by Argo CD, even if its manifest leaves Git
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-postgres
annotations:
argocd.argoproj.io/sync-options: Prune=false
And for automated apps — where no human sees the sync before it runs — PruneLast=true plus the right posture on syncPolicy.automated (covered in Sync Policies: Automated, Self-Heal & Prune) is the difference between “caught it” and “explained it in the postmortem”:
spec:
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- PruneLast=true # apply + heal first; delete only if all that succeeded
# The habit that prevents the disaster: PREVIEW what a sync will prune
argocd app diff payments
# ...lines beginning with '-' under a resource are what would be DELETED...
# Or a full dry run of the sync, applying nothing
argocd app sync payments --dry-run
If an argocd app diff shows a resource you did not mean to delete — stop. That is the moment the disaster is free to prevent and expensive to recover. The classic near-miss is exactly this: an engineer diffs before a refactor sync, sees - PersistentVolumeClaim data-postgres, and realises the moved directory took the PVC’s manifest with it.
Recovery: the runbook when the prune already fired
Prevention failed and the resource is gone. Work this runbook top to bottom — and read the ⚠️ on every destructive-adjacent step, because the wrong recovery move (a blind re-sync, a Replace) can finish off data that was still recoverable.
Step 1 — Freeze. Stop automation before it prunes again or self-heals over your investigation.
# Turn OFF automated sync so nothing else changes while you assess ⚠️
argocd app set payments --sync-policy none
⚠️ Do this first. If the app is automated and you start editing Git or the cluster, self-heal and prune can fight you — reverting a manual PV re-bind, or re-pruning a resource you just recreated. Freeze the loop, then work.
Step 2 — Assess what was actually deleted, and whether the DATA still exists. This is the fork in the road. The manifest is trivially recoverable from Git; the data depends entirely on the reclaim policy and any backup.
# Did the underlying PV (and cloud disk) survive? Look for a Released/Available PV
kubectl get pv | grep -i 'released\|available'
# pvc-8f... 20Gi RWO Retain Released payments/data-postgres retained-ssd
# ^ reclaimPolicy Retain + STATUS Released == the disk is still there, data recoverable
# If reclaimPolicy was Delete, the PV is GONE — check your snapshot/backup instead
kubectl get volumesnapshot -A # if you use CSI snapshots
A PV’s STATUS tells you instantly whether data recovery is even on the table:
PV STATUS |
Meaning | Recovery implication |
|---|---|---|
Bound |
Claimed by a live PVC | Normal — in use |
Available |
Free, unclaimed | Ready to be bound |
Released |
Its PVC was deleted, but the PV (and disk) remains | ✅ Retain worked — re-bindable, data intact |
Failed |
Reclaim failed | Investigate the CSI driver |
| (no PV at all) | The PV was deleted with the PVC | ⚠️ Delete reclaim — disk gone, go to backups |
Step 3 — Restore desired state from Git. Whatever the data situation, get the config back by re-asserting Git. If the prune came from a bad commit, revert it:
# The clean, auditable fix: revert the commit that removed the manifest
git revert <bad-commit-sha>
git push
# Then preview and re-create the objects
argocd app diff payments
argocd app sync payments
Step 4 — Recover the data, by case. The manifest is back but points at an empty volume; now you deal with the bytes:
| Situation after the prune | Data recovery move |
|---|---|
reclaimPolicy: Retain, PV Released |
Clear the PV’s claimRef, re-bind a new PVC to the existing PV ⚠️ |
reclaimPolicy: Delete, CSI snapshot exists |
Restore a new PVC dataSource from the VolumeSnapshot |
reclaimPolicy: Delete, external backup (Velero, pgdump) |
Restore from the backup into a fresh PVC |
reclaimPolicy: Delete, no snapshot, no backup |
⚠️ The data is gone. Nothing recreates it. Declare the loss |
⚠️ Re-binding a
ReleasedPV means editing itsclaimRefso a new PVC can claim it. Do this carefully and only against the correct PV — patching the wrong PV’sclaimRef, or letting an automated app re-provision before you bind, can lose the very volume you were trying to save. Freeze automation (Step 1), identify the PV by its capacity and old claim name, then bind deliberately.
Step 5 — Re-enable automation only after you have verified recovery. Turn the loop back on once the app is Synced/Healthy and the data is confirmed present, not before.
argocd app set payments --sync-policy automated
argocd app get payments # expect Synced / Healthy, and verify the data by hand
The non-negotiable takeaway of this section: Argo CD recovers your configuration, not your data. Re-syncing from Git recreates the PVC object; it does not put your database back. Only a retained PV, a CSI snapshot, or an external backup does — all set up before the prune. For stateful workloads under GitOps, reclaimPolicy: Retain and a real backup (see Argo CD HA & Disaster Recovery) are not optional.
Finalizers and deletion order
Finalizers govern deletion order in Argo CD, and getting the model right prevents both stuck deletions and orphaned resources. The one you set on purpose is the Application finalizer, resources-finalizer.argocd.argoproj.io. Its presence changes what happens when you delete the Application itself:
metadata:
finalizers:
- resources-finalizer.argocd.argoproj.io # cascade: delete the app's resources too
| App has the finalizer? | kubectl delete application / argocd app delete does |
Result |
|---|---|---|
| Yes (cascade) | Deletes the Application and everything it manages | Clean teardown — but ⚠️ this is a full prune of the app’s resources |
No (non-cascade, --cascade=false) |
Deletes only the Application object |
Workloads keep running, now orphaned (unmanaged) |
Both are legitimate, and both are traps if you pick wrong. Deleting an app with the finalizer is a deliberate whole-app prune — every ⚠️ from the pruning section applies, including the PVC and namespace blast radius. Deleting it without (--cascade=false) leaves workloads running but unmanaged: what you want when migrating an app to another Argo CD instance or decommissioning the control plane, and a mess if you did it by accident.
When Argo does cascade, the deletion order is: delete the managed resources, wait for their finalizers to clear, then remove the Application. So a single stuck child finalizer (a pvc-protection on a mounted volume) hangs the whole app deletion — when an app delete hangs, look at the child resource still Terminating, not the Application.
Orphaning cuts both ways. --cascade=false orphans on purpose; but force-removing the app’s finalizer (kubectl patch application ... finalizers:null) to “unstick” a delete also orphans — it removes the app object and leaves every workload running and unmanaged, which looks like success and is actually a slow leak you must later re-adopt or delete by hand.
Out-of-order waves and ApplicationSet mass-deletes
A wave stuck on an unhealthy resource
Sync waves apply resources in ordered groups, and — crucially — Argo CD waits for each wave to become Healthy before starting the next. That gate is the feature (your database comes up before the app that needs it) and the failure mode (if a wave-0 resource never goes Healthy, wave 1 never starts, and the sync sits Running forever). This looks identical to a stuck hook: Phase: Running, a message naming the resource it is waiting on.
argocd app get platform --show-operation | grep Message
# Message: waiting for healthy state of apps/StatefulSet/postgres
# The wave-0 resource that never became Healthy is the whole story
kubectl -n data get statefulset postgres
# NAME READY AGE
# postgres 0/3 22m <- never reached readiness; wave 1 is blocked behind it
kubectl -n data describe pod postgres-0 | tail -20
# ... 0/1 nodes are available: 3 Insufficient memory ... <- the real cause
The fix is never to fight the wave mechanism — it is to fix the resource the wave is correctly waiting on (here, the StatefulSet cannot schedule). If a resource legitimately never reports Healthy (a Job you forgot has no completion, a custom resource with no health check), either give it a health check, move it out of the blocking wave, or annotate it so Argo does not gate on it. terminate-op cancels the stuck sync so you can act, but the wave will re-block on the next sync until the resource can actually go Healthy.
An ApplicationSet that deletes many apps at once
The most dangerous single object in Argo CD is a misconfigured ApplicationSet generator, because it can delete dozens of Applications at once — and each deletion cascades to that app’s resources. The mechanism: an ApplicationSet’s generator produces a set of parameters, one Application per result. If the generator suddenly produces fewer results — a cluster Secret was mislabeled so the cluster generator no longer matches it, a Git directory was renamed so the git generator no longer finds it, a List generator entry was dropped — the ApplicationSet controller sees those Applications as no-longer-desired and deletes them. If those Applications carry the cascade finalizer, their workloads go too.
| Trigger | Effect | Recovery |
|---|---|---|
| Cluster generator: a cluster Secret loses its matching label | Every app for that cluster is deleted | Restore the label; apps regenerate |
| Git generator: a directory is renamed/removed | Every app from that directory is deleted | git revert the rename; apps regenerate |
| List/Matrix generator: an entry is dropped | Those apps are deleted | Restore the entry in Git |
| A generator errors and returns empty (usually safe) | Argo keeps last-known-good; apps preserved | Fix the generator; no deletion should occur |
The recovery is the same shape as any prune: git revert the change that shrank the generator’s output, and the ApplicationSet controller regenerates the Applications, which re-adopt their resources. The danger is when the deleted Applications had the cascade finalizer and the underlying workloads were pruned before you noticed — then you are back in the pruning-disaster runbook (data from backup, not from Git). Two settings blunt the whole class of failure:
spec:
# Do not let the ApplicationSet DELETE Applications automatically ⚠️
syncPolicy:
applicationsSync: create-update # create + update, but never delete
preserveResourcesOnDeletion: true # if an App IS removed, keep its resources
applicationsSync: create-update (or create-only) lets the controller create and update Applications but never delete them, so a mislabeled cluster or moved directory can no longer wipe apps — a human deletes them deliberately. preserveResourcesOnDeletion: true is the second seatbelt: if an App is removed, its resources are kept, not pruned. For any ApplicationSet fanning out production apps, treat both as defaults.
The recovery toolkit
Across every failure above, the same handful of tools do the recovering. Know them cold.
| Tool | Command | Recovers |
|---|---|---|
| Preview a sync | argocd app diff <app> |
Nothing yet — shows what a sync would change/prune |
| Deploy history | argocd app history <app> |
The list of previous synced revisions to roll back to |
| Roll back | argocd app rollback <app> <id> |
The app to a previous known-good revision ⚠️ |
| Revert in Git | git revert <sha> + sync |
The source of truth (the real, auditable fix) |
| Control-plane export | argocd admin export -n argocd > backup.yaml |
A backup of Applications, Projects, and Argo config |
| Control-plane import | argocd admin import -n argocd - < backup.yaml |
Argo CD’s own objects after a control-plane loss |
argocd app history + rollback is Argo CD’s built-in time machine — for config, not data:
argocd app history payments
# ID DATE REVISION
# 0 2026-07-14 09:12:03 +0000 UTC a1b2c3d (v1.4.0)
# 1 2026-07-15 11:40:55 +0000 UTC e4f5g6h (v1.5.0) <- the bad one
# 2 2026-07-15 14:02:19 +0000 UTC a1b2c3d (v1.4.0) <- rolled back
# Roll back to the last good deployment
argocd app rollback payments 0
⚠️
argocd app rollbackis refused (or immediately undone) while automated sync is on — auto-sync would just roll you forward to Git’s HEAD again. Disable automation first (argocd app set <app> --sync-policy none), roll back, verify, and remember that rollback changes the cluster but not Git: your repo still has the bad revision, so the durable fix is always a matchinggit revert. Rollback is the fast stop-the-bleeding move; the Git revert is the cure.
git revert is the GitOps-native cure and the one to prefer whenever you can push a commit: it fixes the source of truth, leaves an audit trail, and works whether or not automation is on.
argocd admin export/import recovers the control plane — the Applications, AppProjects, and Argo CD config that are not in your app repos. It captures that and nothing else:
argocd admin export includes |
It does NOT include |
|---|---|
Application + ApplicationSet objects |
Your app manifests (those live in Git) |
AppProject definitions |
Your application data (PVs, databases) |
| Cluster Secrets, repo creds, RBAC/SSO config | The target clusters’ running workloads |
argocd-cm / argocd-rbac-cm config |
— |
If someone deletes an AppProject or the Argo CD namespace is lost, a scheduled argocd admin export brings the definitions back — it belongs in your toolkit alongside git revert because Git holds your apps’ desired state but not Argo CD’s own config. It is covered end-to-end in Argo CD HA & Disaster Recovery.
The habit that ties the toolkit together: argocd app diff before every re-sync during an incident. After any recovery edit — a revert, a manifest restore, a project widen — preview the sync before you run it. During an incident your mental model of the cluster is stale and stressed; the diff is the ground truth of what your next sync will do, and it is the last place to catch a second mistake before it lands.
Going deeper
The sections above are the field guide; this one is the edge cases the field guide assumes you already know — the messages Part 1’s table does not list, the sync options that change how a delete cascades, and the field-manager fights that only surface at scale.
ResourceQuota and count limits: the SyncFailed that isn’t where you look
When a namespace has a ResourceQuota, a rejected create reads ... is forbidden: exceeded quota: <name>, requested: ..., used: ..., limited: .... The trap is where it appears. If you apply an object the quota limits directly — a PersistentVolumeClaim over a requests.storage quota, a bare Pod, an object under a count/<resource>.<group> quota — Argo CD’s apply is refused and the resource shows SyncFailed. But if you apply a Deployment whose pods breach a CPU/memory quota, the Deployment object itself applies fine — it reads Synced — and the failure surfaces one level down: the ReplicaSet cannot create pods, health goes Degraded, and the exceeded quota message lives in kubectl -n <ns> describe replicaset <rs> events, not in the sync result. So a quota problem is either a SyncFailed (object refused) or a Synced/Degraded (children refused) — check the sync result and the workload’s events.
PrunePropagationPolicy: how a delete cascades
Prune=false decides whether Argo CD deletes; PrunePropagationPolicy decides how the delete cascades to dependents. It maps straight onto Kubernetes’ deletionPropagation:
PrunePropagationPolicy= |
Behaviour | Reach for it when |
|---|---|---|
foreground (default) |
Delete dependents first, wait, then the owner | You want ordered, complete teardown |
background |
Delete the owner immediately; GC dependents async | A foreground prune hangs on a slow dependent |
orphan |
Delete the owner, leave the dependents | You are removing a parent but keeping its children |
Set it like any sync option: argocd.argoproj.io/sync-options: PrunePropagationPolicy=background. It matters most when pruning a parent (a namespace, a controller CR) wedges on a child that will not finalize — switching to background, or fixing the child, unblocks it.
allowEmpty: the built-in guard against a total wipe
Argo CD ships a seatbelt for exactly the disaster in the pruning section. If a sync would leave the app with zero resources — a typo’d path points at an empty directory, a Helm/Kustomize change rendered nothing, an ApplicationSet parameter emptied a release — Argo CD refuses the sync rather than prune everything, failing with a “0 resources found” style error. That refusal is the feature. The allowEmpty=true sync option disables it, permitting an app to legitimately have no resources. Treat allowEmpty=true as a sharp tool: set it only on an app genuinely meant to render empty, never as a reflex to make the error go away — because that error is the guard standing between a bad render and a full prune.
ServerSideApply field-manager conflicts (and the HPA-vs-Argo tug-of-war)
ServerSideApply=true fixes the strategic-merge and 262144-byte-annotation failures, but SSA brings its own class of error: field-manager conflicts. Under SSA every writer is a named field manager (Argo CD applies as argocd-controller), and the apiserver tracks who owns each field. If another controller owns a field you are applying, the apply is refused: Apply failed with 1 conflict: conflict with "..." using apps/v1: .spec.replicas. The textbook case is a HorizontalPodAutoscaler that owns .spec.replicas while your manifest also sets it — Argo CD and the HPA overwrite each other and the app flaps OutOfSync every reconcile. The fix is not to force the field but to cede it:
spec:
syncPolicy:
syncOptions:
- ServerSideApply=true
- RespectIgnoreDifferences=true # honour ignoreDifferences at APPLY time, not just in the diff
ignoreDifferences:
- group: apps
kind: Deployment
jsonPointers:
- /spec/replicas # the HPA owns this — stop fighting over it
RespectIgnoreDifferences=true is the key. Without it, ignoreDifferences only hides the field from the diff, but Argo CD still writes its value on every sync and re-triggers the fight; with it, Argo CD leaves the ignored field alone at apply time too. (More on this in Diffing, Drift & ignoreDifferences.)
FailOnSharedResource: when two Applications fight over one object
If two Applications target the same live resource — a copy-pasted manifest, an overlapping Kustomize base, a Namespace both apps declare — Argo CD’s default is quietly dangerous: each app sees the object as “not matching my Git” and re-applies it, so it flaps between the two apps forever, each sync undoing the other. The FailOnSharedResource=true sync option turns that silent tug-of-war into a loud, correct failure: the sync fails the moment it would touch a resource already tracked by another Application, so you find the ownership overlap instead of chasing an object that changes every few minutes. On any cluster running more than a handful of apps, make it a default.
Reading the application-controller logs — and the Error phase
Most failures live in operationState, but two do not: a phase of Error (Argo CD itself failed to run the operation, versus Failed = a resource was rejected) and any “the controller is behaving strangely” symptom. Both send you to the application-controller logs. It runs as a StatefulSet, and at scale it is sharded — multiple replicas (argocd-application-controller-0, -1, …), each owning a subset of clusters — so the app you care about is reconciled by exactly one shard:
# All controller replicas (a sharded install shows more than one)
kubectl -n argocd get statefulset argocd-application-controller
# Stream logs across all shards, tagged with the pod name, filtered to one app
kubectl -n argocd logs -l app.kubernetes.io/name=argocd-application-controller \
--prefix --tail=200 | grep -i 'payments'
An Error phase is usually infrastructure, not your manifest — a repo-server timeout, an unreachable target cluster, a rate-limited cloud API — and the controller log carries the real reason. This is also where you confirm a suspected controller wedge before you reach for a restart.
Tracing a finalizer to its owning controller
Drill 2 clears a finalizer only after proving it is safe — but on a custom-resource finalizer like database.example.com/finalizer, “safe” means knowing which controller was supposed to run the teardown. Read the clues in order: the finalizer’s domain prefix names the API group (database.example.com → the operator serving that group); kubectl get crd | grep database.example.com finds the CRD; and the resource’s ownerReferences (kubectl get <cr> -o yaml) name the operator’s objects. Find that operator’s Deployment in its namespace and check whether it is running — a finalizer stuck forever almost always means its controller is crashed, uninstalled, or RBAC-blocked. The clean fix is to restore the controller so it finalizes properly. If the operator is gone for good and you must null the finalizer, first delete by hand the cloud object it would have cleaned up (the database, the bucket, the DNS record) — otherwise you clear the Kubernetes object and silently leak the billing cloud resource behind it.
Hands-on lab
These are disaster drills, done at config-and-analysis level so you can rehearse the moves without a live blast radius. Each drill is the real command sequence and representative output (labelled as such — these are not captured from a live run), the ⚠️ where the move is destructive, and a “what just happened” so the reasoning sticks. Run the read-only inspection commands against any Argo CD you have; treat the destructive ones as a script you read and understand before you ever need them in anger. The safest place to actually execute them is a throwaway kind/minikube cluster with a demo app — nothing here should be first-attempted in production.
Drill 1 — A stuck sync → terminate-op
Scenario: a PreSync hook is wedged; the operation has been Running for 15 minutes and nothing else can sync.
# 1. Confirm it is genuinely stuck (Running, unchanging message, minutes old)
argocd app get payments --show-operation | grep -E 'Phase|Message'
# Phase: Running
# Message: waiting for completion of hook batch/Job/db-migrate
# 2. Read WHY before cancelling — the hook's own logs
kubectl -n payments logs job/db-migrate --tail=15
# waiting for advisory lock on 'schema_migrations' ... (representative)
# 3. Cancel the stuck operation
argocd app terminate-op payments
# application 'payments' operation terminating (representative)
# 4. Fix the root cause (release the lock / fix the Job), then re-sync
argocd app sync payments
⚠️
terminate-opcancels the operation; it does not undo what already applied. After terminating, runargocd app getto see what landed before the cancel, so your re-sync starts from a known state.
What just happened: Argo CD runs one operation per app, so a wedged hook froze the whole app. Terminating fixed nothing — it unblocked you. Diagnose (Step 2) before you cancel, or the re-sync in Step 4 just re-wedges.
Drill 2 — A finalizer-stuck resource → safe force-removal
Scenario: you deleted an app and a PVC is stuck Terminating behind pvc-protection.
# 1. See the stuck resource and, critically, WHICH finalizer holds it
kubectl -n payments get pvc data-cache -o \
custom-columns=NAME:.metadata.name,STATUS:.status.phase,FINALIZERS:.metadata.finalizers
# NAME STATUS FINALIZERS
# data-cache Terminating [kubernetes.io/pvc-protection]
# 2. Diagnose the PROTECTING condition — is a pod still mounting it?
kubectl -n payments get pods -o json \
| jq -r '.items[] | select(.spec.volumes[]?.persistentVolumeClaim.claimName=="data-cache") | .metadata.name'
# (empty output == no pod mounts it == the protection is satisfied)
# 3. Only now, force-clear the finalizer ⚠️
kubectl -n payments patch pvc data-cache --type=merge -p '{"metadata":{"finalizers":null}}'
⚠️ Step 2 is the whole drill.
pvc-protectionexists to stop you detaching a volume a pod is using. If Step 2 had listed a pod, the correct fix was to delete that pod — not the finalizer. Patching the finalizer while a pod mounts the PVC can corrupt the filesystem. Force-clear only after you have proven the protecting condition is gone.
What just happened: the finalizer was not a bug — it was Kubernetes protecting a volume. You proved the protection was moot (no mounting pod), then cleared it. The rule: a finalizer is a promise that cleanup will run; force-removing it breaks that promise, so first confirm the cleanup is already done or truly impossible.
Drill 3 — A prune-deleted resource → recover from Git, and the data-loss reality
Scenario: a refactor moved a directory; the automated app pruned a production PVC. This drill rehearses the runbook.
# 1. FREEZE automation before anything else changes ⚠️
argocd app set payments --sync-policy none
# 2. Assess: did the underlying disk survive the prune?
kubectl get pv | grep payments
# pvc-8f2... 20Gi RWO Retain Released payments/data-postgres retained-ssd
# ^Retain ^Released == disk still exists, data recoverable
# (if you see NO such PV and reclaimPolicy was Delete, the disk is GONE -> go to backups)
# 3. Restore the CONFIG from Git (revert the commit that dropped the manifest)
git revert <bad-commit-sha> && git push
argocd app diff payments # PREVIEW: confirm the PVC is coming back, nothing else pruned
argocd app sync payments # recreate the objects
# 4. Recover the DATA (only possible because reclaimPolicy was Retain)
# Clear the old PV's claimRef so the fresh PVC can bind to the EXISTING disk ⚠️
kubectl patch pv pvc-8f2... --type=merge -p '{"spec":{"claimRef":null}}'
# 5. Verify, THEN re-enable automation
argocd app get payments # Synced / Healthy
argocd app set payments --sync-policy automated
⚠️ Two destructive-adjacent steps. Step 1: if you skip the freeze, self-heal can re-prune or fight your PV re-bind. Step 4: patch the
claimRefon the correct PV only — identify it by capacity and the old claim name; the wrong PV loses different data.
What just happened: the config came back from Git in seconds (Step 3) — that part is always easy. The data came back only because someone earlier set reclaimPolicy: Retain (Step 4). Re-run this drill imagining reclaimPolicy: Delete: Steps 1–3 are identical, but Step 4 cannot exist — the disk died with the PV, and your only path is a backup. That contrast is the entire point of the pruning section.
Drill 4 — A failed PreSync hook → fix and retry
Scenario: a database migration Job failed; the sync is Failed and the new version never rolled.
# 1. Read the operation — one hook resource failed
argocd app get payments --show-operation | grep -A2 Hook
# GROUP KIND NAMESPACE NAME STATUS HOOK MESSAGE
# batch Job payments db-migrate Failed PreSync job failed BackoffLimitExceeded
# 2. The failure reason is ALWAYS in the hook pod's logs
kubectl -n payments logs job/db-migrate --tail=20
# ERROR: relation "orders" does not exist (SQLSTATE 42P01) (representative)
# 3. Fix the migration in Git (make it idempotent / fix the ordering), commit, push
# then retry the sync — the wave resumes from where it failed
argocd app sync payments --retry-limit 2
⚠️ Before you re-run a data-mutating migration, confirm it is idempotent. A non-idempotent migration that partially applied can corrupt on the second run. If unsure, inspect the DB state the first run left behind before retrying.
What just happened: the Deployment was fine — a PreSync hook exiting non-zero failed the whole sync, so the new pods never rolled (the coupling is the feature: no migration, no new code). You read the failed hook’s logs, fixed the migration in Git, and re-synced. The failed Job was kept (not auto-deleted) precisely so you could read those logs.
Teardown
No cloud resources are created by the read-only steps. If you rehearsed the destructive commands on a throwaway cluster, delete it entirely so nothing lingers:
kind delete cluster --name argocd-drills # or: minikube delete
What just happened: the state-mutating drills (patching finalizers, clearing claimRef, force-finalizing namespaces) are exactly the ones you never want to first attempt in production — a disposable cluster is where you build the muscle memory so the real incident is a rehearsal.
Practice challenges
Six exercises that escalate from “read the state” to “recover under fire.” Do them against a throwaway kind/minikube cluster, or reason them through on paper — every solution is a real command or manifest. (The guided walk-throughs are in the lab above; these are the ones where you produce the answer.)
1. (Beginner) Triage first. An app named checkout “won’t deploy.” In one command, show its operation phase and the message that says why.
<details> <summary>Show solution</summary>
argocd app get checkout --show-operation
# or, machine-readable:
kubectl -n argocd get app checkout \
-o jsonpath='{.status.operationState.phase}{"\n"}{.status.operationState.message}'
Why: phase-first triage. Failed → read the rejected resource’s message; Running forever → a stuck op to terminate-op; Error → Argo itself, go to the controller logs.
</details>
2. (Beginner) Seatbelt a crown-jewel PVC. Add the one annotation that stops Argo CD from ever pruning a PersistentVolumeClaim named data-postgres, even if its manifest leaves Git.
<details> <summary>Show solution</summary>
metadata:
name: data-postgres
annotations:
argocd.argoproj.io/sync-options: Prune=false
Why: a moved directory or a dropped file can no longer delete this object — the single cheapest guard against the pruning disaster. </details>
3. (Intermediate) Immutable field, two different answers. A Deployment is SyncFailed: field is immutable on spec.selector. Give the scoped fix — and say why you would not apply the same fix to a PersistentVolumeClaim.
<details> <summary>Show solution</summary>
# on the Deployment only:
metadata:
annotations:
argocd.argoproj.io/sync-options: Replace=true
Why: Replace delete-and-recreates. For a Deployment that is brief pod churn; for a PVC it binds a new empty volume and the data is gone. Stateful objects get a rename-and-migrate or a maintenance window — never a blind Replace.
</details>
4. (Intermediate) Make an automated app prune-safe. Write the spec.syncPolicy for an app that auto-syncs, self-heals, and prunes — but orders deletes last so a mid-sync error cannot delete a still-referenced resource.
<details> <summary>Show solution</summary>
spec:
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- PruneLast=true # apply + heal succeed first; delete only then
Why: PruneLast=true guarantees deletions happen only after every apply succeeded and is healthy. Pair it with reclaimPolicy: Retain and Prune=false on the stateful resources for defence in depth.
</details>
5. (Advanced) Stop the HPA-vs-Argo flap. An app flips OutOfSync every few minutes because a HorizontalPodAutoscaler and your manifest both set spec.replicas. Write the two-part fix.
<details> <summary>Show solution</summary>
spec:
syncPolicy:
syncOptions:
- ServerSideApply=true
- RespectIgnoreDifferences=true
ignoreDifferences:
- group: apps
kind: Deployment
jsonPointers:
- /spec/replicas
Why: ignoreDifferences alone only mutes the diff — Argo still writes replicas each sync and keeps fighting. RespectIgnoreDifferences=true makes it leave the HPA-owned field alone at apply time too.
</details>
6. (Advanced) The green-but-empty database. After a prune deleted a PVC, you git revert + sync and the app reads Synced/Healthy — but the database is empty. Name the single earlier decision that determines whether the data is recoverable, and the exact PV STATUS you would hope to see.
<details> <summary>Show solution</summary>
The StorageClass reclaimPolicy. With reclaimPolicy: Retain, the PV survived the PVC deletion and reads STATUS: Released — clear its claimRef and re-bind a new PVC to recover the data. With reclaimPolicy: Delete (the AKS/EKS/GKE default), the PV and cloud disk were destroyed with the PVC; there is no PV left, and only a CSI snapshot or an external backup can bring the data back.
Why: git revert restored the config (the app went green); only a retained PV or a backup restores the data. Green means live matches Git, not that the bytes are intact.
</details>
Common mistakes and troubleshooting
The table below is the operational-failure companion to Part 1’s status table. Every symptom is a real Argo CD/Kubernetes state or message.
| Symptom | Cause | Fix |
|---|---|---|
Resource SyncFailed: field is immutable |
Changed an immutable field (selector, clusterIP, PVC class/size) |
Replace=true for stateless; rename/migrate for stateful ⚠️ |
SyncFailed: ... is not permitted in project |
AppProject denies the destination or resource kind |
Widen the project deliberately, or fix the app’s destination/kind |
SyncFailed: admission webhook ... denied the request |
A policy controller (Kyverno/Gatekeeper) rejected the object | Satisfy the policy in the manifest, or amend the policy |
SyncFailed: failed calling webhook ...: context deadline exceeded |
The webhook backend is down; apiserver fails closed | Restore the webhook service; review its failurePolicy |
SyncFailed: Job "db-migrate" ... BackoffLimitExceeded |
A PreSync hook Job failed; the whole sync failed with it | Read kubectl logs job/<hook>, fix, argocd app sync --retry |
Operation Running forever, waiting for completion of hook |
A hook Job never completes (lock, dependency, bad command) | argocd app terminate-op, fix the Job, re-sync |
Operation Running forever, waiting for healthy state of ... |
A wave-N resource never reaches Healthy; later waves blocked | Fix that resource (scheduling, readiness); it is the real cause |
Resource stuck Terminating behind a finalizer |
The controller that clears the finalizer is gone/blocked | Diagnose the protecting condition; force-clear only if truly satisfied ⚠️ |
Namespace stuck Terminating |
A child API object could not be finalized | Find + delete the child; force via /finalize orphans stragglers ⚠️ |
| A prod PVC/Namespace vanished after a Git refactor | prune: true deleted a resource that left Git (moved dir, bad path) |
Freeze; git revert; re-sync; recover DATA from retained PV/backup ⚠️ |
| Dozens of apps deleted at once | An ApplicationSet generator returned fewer results (mislabel, rename) | git revert the change; set applicationsSync: create-update |
Rollout Suspended, sync will not complete |
A canary paused on a manual step — not a failure | kubectl argo rollouts promote <ro> (or abort) |
Replace=true on a PVC created a new empty volume |
Replace deletes+recreates; the recreated PVC bound a fresh disk |
Restore data from snapshot/backup; never Replace stateful storage ⚠️ |
argocd app rollback does nothing / re-syncs forward |
Automated sync is on; it rolls you back to Git HEAD | Disable auto-sync, rollback, then git revert for the durable fix |
App delete hangs in Terminating |
The app’s cascade finalizer waits on a stuck child resource | Fix the child (its finalizer); do not force the app finalizer blindly ⚠️ |
Three gotchas cause the most damage in practice, and they are worth spelling out beyond the table.
1. Re-syncing on top of a stuck operation does nothing — and hides the problem. Argo CD runs one operation per app. If an operation is Running (stuck), pressing Sync again is a no-op — the request queues behind the wedged one. Engineers who don’t know this hammer Sync, see nothing happen, and escalate, when the fix was to terminate-op first. Check operationState.phase before you re-sync; if it is Running and stale, cancel it, don’t pile on.
2. “Restore from Git” restores config, and people assume it restored data. The most expensive misconception in GitOps operations. After a prune deletes a PVC, git revert + sync recreates the PVC object and the app goes green — everyone relaxes because the UI is healthy. But the recreated PVC bound a new empty volume; the database is empty. Green means config matches Git, not data is intact. Always verify data explicitly after recovering a stateful resource, and know beforehand whether your reclaim policy even makes recovery possible.
3. Force-clearing a finalizer to “unstick” a delete leaks cloud resources. When a namespace or operator-managed resource hangs in Terminating, the tempting fix is to null the finalizers and move on. But the finalizer was a controller’s promise to run teardown — deregister a load balancer, delete a disk, remove a DNS record. Skip it and that cloud object is orphaned: it keeps existing and billing, with no Kubernetes object pointing at it. The clean fix is to make the controller’s cleanup succeed; force-clearing trades a stuck object for a silent leak, so follow it with a sweep of the cloud console for orphaned LBs, disks, and IPs.
Common beginner mistakes
The troubleshooting table maps symptoms to fixes; these are the misconceptions that make an operational failure worse than it needed to be — the wrong mental model, and the right one.
| The assumption | Why it bites | The right mental model |
|---|---|---|
| “Prune is dangerous — I’ll disable it everywhere.” | You lose GitOps drift-correction; resources deleted from Git linger as orphans and configuration rots. | Prune is correct. Scope the risk with Prune=false on stateful objects, reclaimPolicy: Retain, and PruneLast=true — do not switch off the mechanism. |
“Synced means the app is fine.” |
Sync and health are different axes; an app can be Synced and Degraded (live matches Git, but the pods crash-loop). |
Synced = live matches Git. Healthy = the resources actually work. Always read both. |
“SyncFailed means Argo CD is broken.” |
You debug Argo instead of your manifest and lose the real signal. | It is almost always the apiserver correctly refusing your object; the message names your fix (immutable field, policy, RBAC). Argo is the messenger. |
“terminate-op will roll the sync back.” |
You expect an undo and are caught out when half-applied changes remain. | It only cancels the in-flight operation. Whatever already applied stays; it unblocks you to diagnose — it does not revert. |
“argocd app delete just removes the Application object.” |
With the cascade finalizer it prunes every managed resource — the full PVC/Namespace blast radius. | Deleting an app with the finalizer is a whole-app prune; --cascade=false instead orphans the workloads. Know which one you are pressing. |
“Replace=true is a safe way to clear apply errors.” |
On a PVC or StatefulSet it delete-and-recreates and binds a new empty volume — data loss dressed up as a fix. | Fine for stateless objects; never blanket it on stateful storage. Scope it per-resource and keep it off anything data-bearing. |
Cheat-sheet
The commands and settings for reading, cancelling, recovering, and — most importantly — not causing an operational failure.
| Command / setting | What it does |
|---|---|
argocd app get <app> --show-operation |
The operation phase, message, and per-resource sync result |
kubectl -n argocd get app <app> -o jsonpath='{.status.operationState.phase}' |
Machine-readable operation phase (for CI gates) |
argocd app terminate-op <app> |
Cancel a stuck/running sync operation |
argocd app diff <app> |
Preview what a sync would change/prune — run before every incident sync |
argocd app sync <app> --dry-run |
Full dry run; applies nothing |
argocd app sync <app> --retry-limit N |
Re-sync after fixing a failed hook/resource |
argocd app history <app> |
List previous synced revisions (rollback targets) |
argocd app rollback <app> <id> |
Roll the cluster back to a prior revision (auto-sync must be off) ⚠️ |
argocd app set <app> --sync-policy none |
Freeze automation — first move in any recovery ⚠️ |
argocd admin export -n argocd > backup.yaml |
Back up Applications, Projects, Argo config |
git revert <sha> |
The durable, auditable recovery — fix the source of truth |
kubectl argo rollouts promote <ro> |
Continue a paused canary (not a stuck op) |
argocd.argoproj.io/sync-options: Prune=false |
Never prune this resource — put it on crown-jewel PVCs/namespaces |
argocd.argoproj.io/sync-options: Replace=true |
Delete+recreate for immutable-field changes (never on stateful data) ⚠️ |
syncOptions: [PruneLast=true] |
Prune only after apply+heal succeed |
reclaimPolicy: Retain (StorageClass) |
Keep the cloud disk when the PVC is deleted — set before you need it |
applicationsSync: create-update (ApplicationSet) |
Controller may create/update Apps but never delete them |
preserveResourcesOnDeletion: true (ApplicationSet) |
Keep resources if an App is removed |
The prune-safety checklist — run through it before enabling prune on any production app, and again before any sync that follows a Git refactor:
| ✅ Check | Why |
|---|---|
Stateful resources carry Prune=false |
A moved manifest can never delete the PVC |
StorageClass for data uses reclaimPolicy: Retain |
A pruned/deleted PVC leaves the disk intact |
PruneLast=true is set on automated apps |
Deletes happen only after applies succeed |
| A real backup exists (Velero / CSI snapshots / DB dumps) | The only thing that recovers data, not config |
You ran argocd app diff before this sync |
You saw the deletions before they happened |
| Deny sync windows protect prod change-freeze hours | No surprise prunes during protected time |
ApplicationSets use create-update + preserveResourcesOnDeletion |
A bad generator cannot mass-delete apps/resources |
Interview and exam questions
Q: What is the difference between a resource being OutOfSync and SyncFailed?
A: OutOfSync means the live state differs from Git but nothing was attempted — it is a comparison result. SyncFailed means Argo CD tried to apply the resource and the apiserver rejected it — it is an operation result, and the resource’s message carries the verbatim rejection reason (immutable field, webhook denial, project denial, RBAC). OutOfSync is “I would change this”; SyncFailed is “I tried and was refused.”
Q: A sync has been stuck on operation phase Running for 30 minutes with no error. What do you do?
A: It is a stuck operation, not a failed one. Read the operation message (argocd app get --show-operation) — it will name what it is waiting on, almost always a hook Job that never completes or a wave resource that never goes Healthy. Diagnose that resource (its logs/events), then argocd app terminate-op to cancel the wedged operation, fix the root cause, and re-sync. Re-syncing without terminating does nothing — Argo runs one operation per app.
Q: You changed a Deployment’s spec.selector and the resource is now SyncFailed: field is immutable. How do you fix it, and how does the answer change if it were a PersistentVolumeClaim?
A: For the Deployment, add Replace=true (as a sync option/annotation) so Argo CD delete-and-recreates it, or delete it and re-sync — brief pod downtime, no data risk. For a PVC, Replace=true is dangerous: deleting and recreating a PVC binds a brand-new empty volume, so you would lose the data. The safe path for stateful resources is to create a new resource under a different name and migrate, or plan an explicit maintenance window — never blind-Replace a PVC.
Q: Explain exactly how a routine directory rename in the config repo can delete a production database.
A: With prune: true, Argo CD deletes any resource that disappears from Git. A directory rename makes every manifest under the old path “disappear”, so Argo prunes those resources. If a pruned PVC’s StorageClass had reclaimPolicy: Delete (the default on AKS/EKS/GKE), deleting the PVC deletes the PV, which tells the CSI driver to delete the underlying cloud disk — destroying the data. The rename never touched the database on purpose; prune plus a Delete reclaim policy turned a refactor into data loss.
Q: After a prune deletes a PVC, you git revert and re-sync. The app goes Synced/Healthy. Is the data back?
A: No — and assuming yes is the classic, expensive mistake. git revert + sync restores the configuration: it recreates the PVC object, which binds a new empty volume, and the app reports healthy because live now matches Git. The data returns only if the PV was Retain (re-bind the existing disk) or you restore from a CSI snapshot or external backup. Green means config matches Git, not that bytes are intact; always verify data explicitly.
Q: A resource is stuck Terminating behind kubernetes.io/pvc-protection. Walk through the safe fix.
A: Do not immediately null the finalizer. pvc-protection blocks deletion while a pod still mounts the PVC, so first check whether any pod references it. If one does, the correct fix is to delete that pod — the finalizer clears itself. Only if no pod mounts it (the protection is satisfied) but it is still stuck do you force-clear with kubectl patch ... finalizers:null. Force-clearing while a pod mounts the volume can corrupt the filesystem.
Q: What is the resources-finalizer.argocd.argoproj.io finalizer, and what happens if you remove it to unstick an app deletion?
A: It is the Application-level finalizer that makes deleting an Application cascade to deleting all the resources it manages. If a child resource’s finalizer is stuck, the app deletion hangs behind it. Force-removing the app’s finalizer removes the Application object but leaves every workload running and unmanaged — you have orphaned the resources. That is sometimes what you want (migrating between Argo instances), but if done accidentally it is a silent leak; you must then re-adopt or manually delete those resources.
Q: A PreSync migration hook fails. Why doesn’t the new application version deploy, and what is the retry hazard? A: A failed PreSync hook fails the entire sync operation — the Sync and PostSync phases never run — so the new Deployment is never applied. That coupling is intentional: no successful migration, no new code against a mismatched schema. The retry hazard is non-idempotent migrations: if the migration partially applied and you re-sync, a non-idempotent step can error (“already exists”) or, worse, mutate data twice. Migrations under GitOps must be idempotent because the reconcile loop will run them more than once.
Q: How can a single ApplicationSet delete dozens of apps, and how do you prevent it?
A: An ApplicationSet renders one Application per generator result. If the generator suddenly yields fewer results — a cluster Secret loses its matching label, a Git directory is renamed, a List entry is dropped — the controller treats those Applications as no-longer-desired and deletes them, cascading to their resources if they carry the finalizer. Prevent it with applicationsSync: create-update (the controller may create/update but never delete Applications) and preserveResourcesOnDeletion: true (keep resources even if an App is removed). Recover a mass-delete by git revert-ing the change that shrank the generator.
Q: When would you use argocd app rollback versus git revert?
A: rollback is the fast, stop-the-bleeding move — it re-applies a previous synced revision to the cluster immediately, but it does not change Git and is refused/undone while automated sync is on. git revert is the durable, auditable cure — it fixes the source of truth so the change is permanent and reviewed, and it works with automation on. In an incident you often rollback first (with auto-sync disabled) to restore service in seconds, then git-revert to make it stick and re-enable automation.
Q: What does argocd admin export recover that git revert cannot?
A: git revert recovers your applications’ desired state, because that lives in Git. But Argo CD’s own configuration — the Application and AppProject objects, cluster Secrets, RBAC/SSO config, local accounts — is not in your app repos. argocd admin export backs those up, and argocd admin import restores them after a control-plane loss (a deleted project, a lost argocd namespace, a rebuilt cluster). Git covers workloads; the admin export covers the control plane.
Q (scenario): Prod alerts fire: an app is Synced but every pod is SyncFailed: namespaces "payments" not found, right after a refactor. Diagnose and fix.
A: The namespace was pruned or removed from Git and the app was not told to recreate it. Confirm with argocd app get --show-operation (the message names the missing namespace). Short term: add CreateNamespace=true to the app’s sync options, or restore the Namespace manifest in an earlier sync wave, then sync. But first check what else the refactor pruned — a deleted namespace cascades, so verify the PVCs and Services in it survived (and their data) before celebrating a green icon.
Glossary
Plain-language definitions for the terms this lesson introduces or leans on (the status/health/comparison terms are defined in Part 1).
| Term | Meaning |
|---|---|
| Operation | A single sync run Argo CD executes against a cluster — it applies resources, runs hooks, and prunes, moving through phases. Tracked in status.operationState. |
| Operation phase | The state of the current/last operation: Running, Succeeded, Failed (a resource/hook was rejected), Error (Argo itself errored), or Terminating (cancelled). |
| Sync result | The per-resource record inside an operation — for each object, its status (Synced/SyncFailed/…), the apiserver message, and its hook/sync phase. |
SyncFailed |
A resource-level status: Argo CD sent the object to the apiserver and it was rejected. The message names the reason and usually the fix. |
| Prune | Deleting a live resource because it no longer exists in Git. Correct GitOps behaviour — and the source of the worst disasters. |
Prune=false |
A sync option/annotation telling Argo CD to never prune a specific resource, even if its manifest leaves Git. |
PruneLast=true |
Sync option that runs all deletes after every apply has succeeded and is healthy. |
PrunePropagationPolicy |
Sync option controlling how a prune cascades to dependents: foreground (default), background, or orphan. |
allowEmpty=true |
Sync option that disables Argo CD’s guard against a sync which would leave the app with zero resources (the guard stops a bad render from pruning everything). |
| Reclaim policy | A StorageClass/PV setting: Delete (the cloud default — the disk dies with the PVC) or Retain (the disk survives; data recoverable). |
| Finalizer | An entry under metadata.finalizers that blocks deletion until a controller runs its cleanup and removes it. A stuck finalizer wedges an object in Terminating. |
pvc-protection |
The kubernetes.io/pvc-protection finalizer — blocks PVC deletion while a pod still mounts it. |
| Cascade delete | Kubernetes deleting an owner also deletes its dependents — why pruning a Namespace or CRD is catastrophic. |
| Application finalizer | resources-finalizer.argocd.argoproj.io on an Application — makes deleting the app cascade-delete every resource it manages. |
| Resource hook | A resource (usually a Job) run at a defined sync point: PreSync, Sync, PostSync, or SyncFail. A failed PreSync fails the whole sync. |
hook-delete-policy |
Governs when a hook’s Job is deleted: BeforeHookCreation (default), HookSucceeded, or HookFailed. |
| Idempotent | A migration/hook that produces the same result if run more than once — mandatory under GitOps, because the reconcile loop will re-run it. |
terminate-op |
argocd app terminate-op — cancels a stuck/running operation. It unblocks you; it does not roll back or delete. |
| Server-Side Apply (SSA) | Applying on the apiserver rather than the client, with per-field ownership by a named field manager (Argo applies as argocd-controller). Fixes list-merge and annotation-size errors; can raise field-manager conflicts. |
RespectIgnoreDifferences |
Sync option that makes Argo CD honour ignoreDifferences at apply time, not only in the diff — so it stops overwriting fields another controller owns (e.g. HPA replicas). |
FailOnSharedResource |
Sync option that fails the sync if it would touch a resource already tracked by another Application, surfacing ownership conflicts instead of a silent tug-of-war. |
Replace=true |
Sync option that delete-and-recreates a resource for immutable-field changes. Safe on stateless objects; destroys data on a PVC/StatefulSet. |
Rollback vs git revert |
argocd app rollback re-applies a prior synced revision to the cluster (fast, does not touch Git, blocked while auto-sync is on); git revert fixes the source of truth (durable, auditable). |
argocd admin export |
Backs up Argo CD’s own objects (Applications, AppProjects, cluster secrets, RBAC/SSO) — the control-plane state that is not in your app repos. |
Key takeaways
- A sync is an operation with a lifecycle. Read
operationState.phasefirst —Failedmeans a resource/hook was rejected (read its message),Runningforever means a stuck op (terminate it),Terminatingmeans someone cancelled it. Ninety percent of panic is not reading the operation before acting. SyncFailedis a resource that the apiserver refused, and the message names the fix —field is immutable,not permitted in project,admission webhook ... denied,namespaces not found, RBACforbidden. Match the message to the cause; do not blind-retry.- A failed PreSync hook fails the whole sync. A migration Job exiting non-zero blocks the deploy; a hook that never completes hangs the operation. Make migrations idempotent — the reconcile loop will run them more than once.
- Terminate stuck operations with
argocd app terminate-op; it unblocks, it does not fix. Diagnose the blocking hook/resource first, then cancel, then re-sync. - A resource stuck
Terminatingis waiting on a finalizer. Diagnose what the finalizer protects before force-clearing it — a wrongly-cleared finalizer corrupts a mounted volume or leaks a billing cloud load balancer/disk. - Prune is the irreversible failure. A moved directory or bad path can delete a PVC, a Namespace (cascading to everything in it), or a CRD (cascading to every CR of that kind). Prevent with
Prune=false,PruneLast=true,reclaimPolicy: Retain, sync windows, andargocd app diffbefore every sync. - Restoring from Git restores configuration, not data. Re-syncing a pruned PVC recreates an empty volume; the bytes come back only from a retained PV, a CSI snapshot, or a backup — all of which must exist before the disaster. On AKS, EKS and GKE the default reclaim policy is
Delete, so the cloud disk dies with the PVC unless you changed it. - Freeze automation first (
--sync-policy none), recover, verify, then re-enable. During recovery,argocd app diffbefore every re-sync — it is the last place to catch a second mistake before it lands.