The sync finished, every resource shows Synced, and yet the application has said Progressing for an hour. Pipelines that run argocd app wait --health time out, sync waves never advance and PostSync hooks never fire. Nothing looks broken in the cluster.
Progressing is a health status, not a sync status. Argo CD computes it from the state of each resource it manages, and one resource that never reaches Healthy holds the whole application back. This guide shows how to find that resource, how to read the rule Argo CD applies to it, and how to fix the cause or tell Argo CD how to judge the resource. Commands and file paths are from Argo CD 3.6.
How Argo CD decides an application's health
Every resource in the application gets its own health status. The application's health is the worst status among its immediate child resources, using this order from best to worst: Healthy, Suspended, Progressing, Missing, Degraded, Unknown. One Ingress in Progressing next to twenty healthy Deployments gives you a Progressing application.
Health isn't inherited down the tree. A Deployment is judged from its own status fields, not from the health of its Pods. Argo CD only rolls up the resources that come from your manifests, so a crashing Pod affects the application through its Deployment's rollout status, not directly.
Each resource kind has its own rule. Some are written in Go inside Argo CD (Deployment, StatefulSet, DaemonSet, ReplicaSet, Service, Ingress, PersistentVolumeClaim, Pod, Job, HorizontalPodAutoscaler, APIService and Argo Workflows), many custom resources have bundled Lua checks, and you can add or override checks in argocd-cm. Most stuck applications come down to one of those rules waiting for a status field that will never be set.
Step 1: find the resource that holds the application
Start with the application summary. The wide output lists every resource with its sync and health status.
argocd app get shop-web
Look for any row whose HEALTH column isn't Healthy. The MESSAGE column in this view is the last sync result for that resource, so it usually says something like deployment.apps/web configured, which is not what you want. The health message lives elsewhere.
argocd app get shop-web -o tree=detailed
The detailed tree prints a REASON column for every resource, including child objects such as ReplicaSets and Pods. That column holds the health message written by the health check. For a Deployment it reads like Waiting for rollout to finish: 0 of 3 updated replicas are available..., which already tells you where to look.
Argo CD 3.6 also names the culprit in the event it emits when the application's health changes. The message gets a (Caused by ...) suffix listing up to three resources.
kubectl get events -n argocd \
--field-selector involvedObject.kind=Application,involvedObject.name=shop-web \
--sort-by=.lastTimestamp
Don't go looking for the answer in the Application's status.resources[].health with kubectl. Since Argo CD 3.0 the controller stores resource health outside the Application custom resource by default, to cut the write load caused by health churn. Unless someone set controller.resource.health.persist: "true" in argocd-cmd-params-cm, those fields are empty and the CLI or UI is the place to read them.
Step 2: check the workload rollout
Deployments are the most common culprit, and the rule is easy to follow once you see it. Argo CD borrows the logic from kubectl rollout status. The Deployment is Progressing while the controller hasn't observed the latest generation, while fewer replicas are updated than desired, while old replicas are still terminating, or while updated replicas aren't yet available.
When the new Pods can't start (a bad image tag, a failing readiness probe, a missing Secret), the Deployment stays Progressing. It only flips to Degraded when Kubernetes sets the ProgressDeadlineExceeded reason on the Progressing condition, which happens after progressDeadlineSeconds. The default is 600 seconds, so for ten minutes Argo CD reports a broken rollout as if it were still on its way.
kubectl -n shop rollout status deployment/web --timeout=10s
kubectl -n shop get pods -l app=web
kubectl -n shop describe pod -l app=web | tail -n 20
The fix here is in the workload, not in Argo CD. Correct the image or the probe in Git and let Argo CD sync again. If ten minutes is too long for your pipelines to learn that a rollout failed, lower progressDeadlineSeconds in the Deployment spec. A value like 180 suits most stateless services.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: shop
spec:
progressDeadlineSeconds: 180
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: ghcr.io/example/web:2.4.0
ports:
- containerPort: 8080
StatefulSets follow a similar rule. They stay Progressing until the controller has observed the latest generation and every replica is ready, so one Pod stuck in CrashLoopBackOff is enough. With a partitioned rolling update, the rollout counts as done once the Pods at or above the partition ordinal are updated.
Step 3: check Services and Ingresses waiting for an address
The second classic is a resource waiting on a load balancer. A Service of type LoadBalancer is healthy only once status.loadBalancer.ingress has at least one entry. An Ingress follows the same rule for its own status.loadBalancer.ingress. Every other Service type is healthy as soon as it exists.
kubectl -n shop get service web -o jsonpath='{.status.loadBalancer}'
kubectl -n shop get ingress web -o jsonpath='{.status.loadBalancer}'
An empty result explains the Progressing status. For a Service, it usually means nothing in the cluster implements load balancers, which is the norm on bare metal and in local clusters until you install something like MetalLB or Cilium's LB IPAM. For an Ingress, the controller isn't writing its address back to the resource. Some controllers only do this when told which Service fronts them. Traefik, for example, needs publishedService enabled in its Kubernetes Ingress provider.
providers:
kubernetesIngress:
publishedService:
enabled: true
Fix the controller where you can, because a populated status also helps tools like external-dns. When you can't, override the health check, which Step 5 covers.
Step 4: check volumes and sync waves
A PersistentVolumeClaim is Healthy when its phase is Bound, Progressing while Pending and Degraded when Lost. On its own, a pending claim just delays the application. Combined with sync waves it can block a sync forever.
During a sync, Argo CD applies one wave, waits until every resource in it is healthy, and only then applies the next. Many storage classes use volumeBindingMode: WaitForFirstConsumer, so a claim stays Pending until a Pod that uses it is scheduled. Put the claim in wave 0 and the Deployment that mounts it in wave 1, and you get a deadlock. The claim waits for a Pod, and Argo CD won't create the Pod until the claim is healthy.
kubectl get storageclass -o custom-columns=NAME:.metadata.name,BINDING:.volumeBindingMode
kubectl -n shop get pvc
The fix is to put the claim in the same wave as its consumer, or later. Drop the argocd.argoproj.io/sync-wave annotation from the claim, or give it the same value as the Deployment. Then terminate the stuck operation and sync again.
argocd app terminate-op shop-web
argocd app sync shop-web
The same trap catches app-of-apps setups. Argo CD removed the built-in health check for argoproj.io/Application in version 1.8. A parent application that orders its children with sync waves has no way to wait for them unless you add that check back, as described in the health documentation.
Step 5: override or add a health check
Some resources will never report what Argo CD expects. A custom resource with no bundled check, an Ingress behind a controller that never writes status, or a CRD whose status format changed are all common. For these, write a Lua health check in argocd-cm under the key resource.customizations.health.<group>_<kind>. Core resources have no group, so the key uses the kind alone, as in resource.customizations.health.Service.
A check configured this way takes priority over the Go-based built-in check. This one treats every Ingress as healthy once it exists, which is a reasonable choice when your controller never publishes an address.
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
labels:
app.kubernetes.io/name: argocd-cm
app.kubernetes.io/part-of: argocd
data:
resource.customizations.health.networking.k8s.io_Ingress: |
hs = {}
hs.status = "Healthy"
hs.message = "Ingress status not published by this controller"
return hs
For a custom resource, read the controller's status and map it to Healthy, Progressing, Degraded or Suspended. Compare status.observedGeneration with metadata.generation when the CRD provides it, or the check can report a stale Healthy right after a change. Standard Lua libraries are disabled in these scripts by default. Enable them per kind with resource.customizations.useOpenLibs.<group>_<kind> only if you really need them.
Test a check against a real object before you roll it out. The admin CLI runs the Lua from a local copy of the ConfigMap, with no change to the cluster.
kubectl -n shop get ingress web -o yaml > ingress.yaml
kubectl -n argocd get configmap argocd-cm -o yaml > argocd-cm.yaml
argocd admin settings resource-overrides health ./ingress.yaml --argocd-cm-path ./argocd-cm.yaml
Wildcards are only allowed in the multi-line resource.customizations key, since Kubernetes ConfigMap keys can't contain *. Use that form to cover every kind in an API group, such as all Crossplane AWS resources, with a single script.
Step 6: exclude a resource instead of fixing it
Sometimes a resource's health just isn't your application's concern. A Pod created by an operator with restartPolicy: Never and meant to run forever is one example. Argo CD treats such Pods like Jobs and reports them Progressing until they succeed, which means never.
Argo CD 3.6 added an annotation for this case. With argocd.argoproj.io/ignore-restart-policy: "true", a running Pod is judged like a long-running one and becomes Healthy once ready. It still turns Degraded if it fails, and the annotation has no effect on hook Pods.
metadata:
annotations:
argocd.argoproj.io/ignore-restart-policy: "true"
For the blunter option, set argocd.argoproj.io/ignore-healthcheck: "true" on an immediate child resource. Its health then stops counting toward the application's health entirely. I'd keep this for resources you truly don't care about. An ignored Deployment that crashes will no longer turn the application red, and that's usually the signal you wanted.
Keeping it from coming back
Most of these problems show up the first time a new kind of resource lands in an application, so catch them there. Run argocd app wait shop-web --health --timeout 300 in the pipeline right after a sync, and fail loudly with the output of argocd app get shop-web -o tree=detailed when it times out. The REASON column in that output is usually enough to point at the right step in this guide.
Keep your custom health checks in Git next to the rest of your Argo CD configuration, with a test object for each one. When an operator upgrade changes its status format, rerunning argocd admin settings resource-overrides health against a fresh object tells you before your applications do.