# How to fix an Argo CD application stuck in Progressing

> Find the resource that keeps an Argo CD application in Progressing, understand the health rule behind it, and fix it with the right change or override.

Published: 2026-11-12
Author: k8wiz Team
URL: https://k8wiz.com/articles/fix-an-argo-cd-application-stuck-in-progressing

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.

```bash
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.

```bash
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.

```bash
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.

```bash
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.

```yaml
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.

```bash
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.

```yaml
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.

```bash
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.

```bash
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.

```yaml
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.

```bash
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.

```yaml
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.
