Skip to content

Fix: ArgoCD Not Working — OutOfSync, Sync Waves, RBAC, Helm/Kustomize, and Webhook Setup

FixDevs · (Updated: )

Part of:  Docker, DevOps & Infrastructure

Quick Answer

How to fix ArgoCD errors — application stuck OutOfSync, sync waves not respected, RBAC permission denied, Helm values not merged, ApplicationSet generator config, repo auth, and webhook not triggering.

The Error

You commit a change to your manifests but ArgoCD never picks it up:

NAME       SYNC STATUS   HEALTH STATUS
my-app     OutOfSync     Healthy

Or auto-sync is enabled but the diff stays:

spec:
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
# Still OutOfSync after 30 minutes.

Or sync waves don’t run in the order you expected:

metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "1"
# Wave 1 resources start AFTER wave 5 finished.

Or you grant a user permission but they still get permission denied:

ERROR: code = PermissionDenied desc = permission denied: applications, sync, default/my-app

Why This Happens

ArgoCD compares the Git state to the cluster state and reports diffs. Most issues come from one of:

  • Sync polling vs webhook. By default ArgoCD polls Git every 3 minutes. A change you just pushed may not appear for that long. Webhooks bring it down to seconds.
  • Auto-sync conditions. Auto-sync runs only when ArgoCD’s sync controller is healthy and there are no manual --dry-run syncs in flight and the resource is sync-eligible. Resources excluded via ignoreDifferences or with a finalizer hold may stay OutOfSync forever.
  • Sync waves vs hooks. Sync waves group resources by sync-wave annotation; lower numbers go first. But hooks (PreSync/Sync/PostSync) cross-cut waves — a PreSync hook for wave 5 still runs before wave 1’s main resources.
  • RBAC is layered. Three levels combine: cluster-level (RoleBindings), project-level (AppProject), and global ArgoCD RBAC (configmap or OIDC group mapping). A “permission denied” can come from any layer.

A second class of failures comes from misunderstanding ArgoCD’s reconciliation model. ArgoCD is a pull-based GitOps controller — it observes Git, computes the desired state, and reconciles the cluster toward it on its own schedule. It is not triggered by your kubectl apply, your CI job, or any external push. If you kubectl apply an Application directly, ArgoCD eventually picks it up. If a CI pipeline runs kubectl set image on a Deployment ArgoCD manages, ArgoCD reverts the change at the next sync window. The clearer your team’s mental model of “Git is the only writer,” the less you’ll fight the controller.

A third class comes from manifest generation. ArgoCD’s repo server clones Git, runs helm template or kustomize build, and feeds the rendered YAML to the application controller. Failures in the chart or kustomization (missing values file, typo in a patch path, post-renderer crash) surface as ComparisonError with cryptic upstream messages. Reading the repo-server pod logs (kubectl logs -n argocd argocd-repo-server-...) is the fastest way to debug them.

Fix 1: Check Application Status in Detail

The CLI shows the full picture:

argocd app get my-app

Output includes:

  • Sync status with the exact resources OutOfSync.
  • Health status with messages for each resource.
  • Conditions explaining why sync isn’t progressing.

For diffs:

argocd app diff my-app

Or from the UI: Application → DIFF tab. Drift comes in three flavors:

  • Expected diffs (changes you intend to apply).
  • Drift in the cluster (manual kubectl edit, controllers mutating resources).
  • Ignored fields that ArgoCD treats as never-equal but doesn’t surface.

For the third, check spec.ignoreDifferences on the Application and resource.customizations.ignoreDifferences in the ArgoCD ConfigMap.

Pro Tip: Run argocd app sync my-app --dry-run to see what would change without actually applying. Useful for verifying a fix before clicking Sync.

Fix 2: Configure a Webhook Instead of Polling

Polling every 3 minutes is fine for batch updates. For tight feedback loops, set up a Git webhook:

# In GitHub → Settings → Webhooks:
URL:          https://argocd.example.com/api/webhook
Content-Type: application/json
Secret:       (your webhook secret)
Events:       Just the push event

In ArgoCD’s argocd-secret:

apiVersion: v1
kind: Secret
metadata:
  name: argocd-secret
  namespace: argocd
data:
  webhook.github.secret: <base64-secret>

After applying, pushes to Git trigger ArgoCD to re-evaluate immediately. The “sync” itself still respects auto-sync rules; webhooks just remove the poll delay.

Common Mistake: Setting the webhook to “Pull request” events. ArgoCD wants push events to pick up new commits to tracked branches. PR events fire on PR creation but not on the actual push that updated the branch.

Fix 3: Auto-Sync With prune and selfHeal

For full GitOps (cluster matches Git, period), enable auto-sync with both flags:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
spec:
  destination:
    namespace: my-app
    server: https://kubernetes.default.svc
  project: default
  source:
    repoURL: https://github.com/myorg/manifests
    path: apps/my-app
    targetRevision: main
  syncPolicy:
    automated:
      prune: true        # Delete resources removed from Git.
      selfHeal: true     # Revert manual changes in the cluster.
    syncOptions:
      - CreateNamespace=true
      - ApplyOutOfSyncOnly=true
      - PrunePropagationPolicy=foreground

Three syncOptions worth knowing:

  • CreateNamespace=true — ArgoCD creates destination.namespace if it doesn’t exist.
  • ApplyOutOfSyncOnly=true — only apply the resources that differ (faster, less churn).
  • PrunePropagationPolicy=foreground — wait for dependents (pods) to delete before removing parents (deployments).

Common Mistake: Setting selfHeal: true without realizing it kills any manual kubectl edit. If you scale a Deployment from the CLI, ArgoCD reverts it on the next sync. Either commit changes to Git, or set ignoreDifferences for spec.replicas on Deployments you want to scale manually:

spec:
  ignoreDifferences:
    - group: apps
      kind: Deployment
      jsonPointers:
        - /spec/replicas

Fix 4: Sync Waves and Hooks

Use sync-wave to order resources within a single sync:

# Database first:
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  annotations:
    argocd.argoproj.io/sync-wave: "0"

# Then migrations:
apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrate
  annotations:
    argocd.argoproj.io/sync-wave: "1"
    argocd.argoproj.io/hook: PreSync

# Then the app:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
  annotations:
    argocd.argoproj.io/sync-wave: "2"

Hooks add lifecycle:

  • PreSync — runs before the main sync. Wait for completion before continuing.
  • Sync — runs as part of the regular sync.
  • PostSync — runs after the main sync, after all resources are healthy.
  • SyncFail — runs only when the sync fails (cleanup hook).

Combine hook and sync-wave to express dependencies:

metadata:
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/sync-wave: "0"
    argocd.argoproj.io/hook-delete-policy: HookSucceeded  # Clean up the Job after success

hook-delete-policy controls when ArgoCD removes the hook resource (HookSucceeded, HookFailed, BeforeHookCreation).

Fix 5: Helm Values Across Environments

For Helm charts, use valueFiles and values in the Application:

spec:
  source:
    repoURL: https://github.com/myorg/charts
    path: charts/my-app
    targetRevision: main
    helm:
      releaseName: my-app
      valueFiles:
        - values.yaml
        - values-prod.yaml
      values: |
        image:
          tag: v1.2.3

valueFiles are merged in order; later files override earlier. The inline values block is highest priority.

For multi-environment GitOps with a single source of truth, use ApplicationSets:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: my-app
spec:
  generators:
    - list:
        elements:
          - env: dev
            url: https://dev.k8s.example.com
          - env: prod
            url: https://prod.k8s.example.com
  template:
    metadata:
      name: "my-app-{{env}}"
    spec:
      project: default
      source:
        repoURL: https://github.com/myorg/manifests
        path: apps/my-app
        targetRevision: main
        helm:
          valueFiles:
            - values.yaml
            - "values-{{env}}.yaml"
      destination:
        server: "{{url}}"
        namespace: my-app
      syncPolicy:
        automated: { prune: true, selfHeal: true }

One ApplicationSet generates one Application per environment, using template substitution.

Pro Tip: For dynamic discovery, use the git or cluster generators instead of list. cluster auto-creates Applications for every registered cluster matching a label.

Fix 6: Kustomize Overlays

For Kustomize-based projects:

spec:
  source:
    repoURL: https://github.com/myorg/manifests
    path: overlays/prod
    targetRevision: main
    kustomize:
      images:
        - my-app=registry.example.com/my-app:v1.2.3
      namePrefix: prod-
      commonLabels:
        env: prod

kustomize.images rewrites image tags without touching Git — useful for image-based deploy pipelines that update the tag via argocd app set:

argocd app set my-app --kustomize-image my-app=registry.example.com/my-app:v1.2.4

The change is persisted on the Application spec (not in Git), and the new tag is reflected at the next sync.

Note: Using argocd app set for image updates is the “imperative-on-top-of-declarative” pattern. Some teams prefer this for fast deploys; purists insist on PR-driven tag bumps. Both work.

Fix 7: RBAC Across Project and Global

ArgoCD has two RBAC layers:

Global RBAC (in argocd-rbac-cm ConfigMap):

apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-rbac-cm
  namespace: argocd
data:
  policy.default: role:readonly
  policy.csv: |
    p, role:devs, applications, sync, default/*, allow
    p, role:devs, applications, get, default/*, allow
    g, github-team:engineering, role:devs

Project RBAC (in AppProject):

apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: payments
  namespace: argocd
spec:
  sourceRepos: ["https://github.com/myorg/payments-manifests"]
  destinations:
    - namespace: payments
      server: https://kubernetes.default.svc
  clusterResourceWhitelist:
    - group: ""
      kind: Namespace
  namespaceResourceBlacklist:
    - group: ""
      kind: ResourceQuota
  roles:
    - name: developer
      policies:
        - p, proj:payments:developer, applications, sync, payments/*, allow
      groups:
        - github-team:payments

A user needs permissions at both layers. The Project layer scopes what their global role can touch (only Apps in payments, only sources from a specific repo).

Common Mistake: Defining a permissive global RBAC but a narrow Project. The Project wins — users get the intersection of permissions.

To debug, use argocd account can-i:

argocd account can-i sync applications payments/my-app
# yes / no

Fix 8: Repository Authentication

For private Git repos:

# HTTPS with PAT:
argocd repo add https://github.com/myorg/manifests \
  --username myorg \
  --password $GITHUB_TOKEN

# SSH:
argocd repo add [email protected]:myorg/manifests.git \
  --ssh-private-key-path ~/.ssh/id_ed25519

# GitHub App (multi-repo, scalable):
argocd repo add https://github.com/myorg \
  --github-app-id 12345 \
  --github-app-installation-id 67890 \
  --github-app-private-key-path ./private-key.pem

For multi-repo orgs, GitHub App auth is best — one credential for all repos under the org, with rate limits scaled by installation.

Repository creds can also be declared via Kubernetes Secrets with argocd.argoproj.io/secret-type: repo-creds:

apiVersion: v1
kind: Secret
metadata:
  name: github-org
  namespace: argocd
  labels:
    argocd.argoproj.io/secret-type: repo-creds
stringData:
  url: https://github.com/myorg
  githubAppID: "12345"
  githubAppInstallationID: "67890"
  githubAppPrivateKey: |
    -----BEGIN PRIVATE KEY-----
    ...

Apply this once, and any Application referencing a repo under myorg uses this credential.

ArgoCD vs Flux vs Spinnaker vs Tekton vs Jenkins X: Which GitOps / CD Tool Fits Your Stack?

ArgoCD is the most popular GitOps controller for Kubernetes, but it’s not the only model. Understanding what the alternatives optimize for usually clears up why ArgoCD does (or doesn’t) feel natural for your workflow.

ArgoCD is a pull-based, application-centric GitOps controller. You define an Application resource pointing at a Git path, and the controller renders Helm/Kustomize/plain YAML and syncs the result. It has a polished web UI, sync waves, hooks, ApplicationSets for multi-cluster fan-out, and a clear RBAC story. It treats the Application as the unit of deployment, which matches how most teams think about services.

Flux is the other major Kubernetes GitOps controller. Same core idea — pull from Git, reconcile cluster state — but Flux is more modular. Sources (Git, Helm, OCI), Kustomizations, and HelmReleases are separate CRDs you compose. There’s no built-in UI; you observe state with flux get or build your own dashboard. Flux fits teams that want everything as Kubernetes resources and prefer a smaller, more focused controller over ArgoCD’s richer UX. The two are functionally close; the choice is mostly aesthetic and ecosystem.

Spinnaker is a different category — multi-cloud continuous delivery with pipelines, canary analysis, manual judgment stages, and deep AWS/GCP/Azure integration. It deploys to Kubernetes too, but also to VMs, App Engine, ECS, and more. Spinnaker is overkill for “deploy this chart to one cluster” but the right tool for “deploy across AWS, GCP, and on-prem with automated canary analysis and manual approval gates.” Adopt Spinnaker when you need pipelines as first-class objects with state, branching, and complex rollout strategies.

Tekton is a Kubernetes-native CI/CD pipeline framework — Tasks and Pipelines as CRDs, each step in its own pod. A build orchestrator, not a GitOps controller. Teams pair Tekton (build and push images) with ArgoCD or Flux (sync the new image tag).

Jenkins X combines a CI pipeline runner with GitOps deployment, now restructured around Lighthouse + Tekton. It opinions you into “preview environments per PR, promote to staging, promote to prod.” Adoption has shrunk as ArgoCD and Flux took the GitOps mindshare and GitHub Actions / GitLab CI took the pipeline mindshare.

For a typical Kubernetes shop in 2026: ArgoCD or Flux for deployment, GitHub Actions or Tekton for image builds, Spinnaker only when you need multi-cloud canary analysis.

Still Not Working?

A few less-obvious failures:

  • ComparisonError: rpc error: code = Unknown desc = Manifest generation error. Helm or Kustomize failed. Check argocd app get output for the underlying error — usually a missing values file or kustomization.yaml path.
  • OutOfSync for a resource with no diff visible. A controller is mutating a field ArgoCD doesn’t ignore. Add ignoreDifferences for that field, or annotate the resource with argocd.argoproj.io/compare-options: IgnoreExtraneous.
  • Sync succeeds but pods crash. Health check, not sync. Look at argocd app get → “Health” section for the failing resource. Custom health checks via resource.customizations.health.GROUP_KIND in the ArgoCD ConfigMap.
  • App stuck Progressing → Degraded loop. Resource has a lifecycle mismatch. Common culprits: an Ingress without DNS, a Service expecting a missing Endpoint.
  • InvalidSpecError after Application apply. The Application’s YAML is invalid (typo in repoURL, wrong project name, missing destination). kubectl describe application <name> -n argocd shows details.
  • Sync hangs on PreSync hook. The hook Job/Pod is stuck Pending or failing. kubectl get pod -n my-app -l job-name=db-migrate to inspect.
  • Application out of sync after every sync. ArgoCD applies what’s in Git, but a controller (HPA, Operator) immediately changes a tracked field. Add ignoreDifferences for that field.
  • Resources stuck in deletion (Terminating). Finalizers haven’t run. argocd app sync my-app --prune --force may not help — finalizers run when the owner is gone. Manually remove finalizers if you’re sure.
  • ApplicationSet generators don’t refresh. A list generator only re-evaluates when the ApplicationSet itself changes. For dynamic discovery, use git (re-evaluates on commit) or cluster (re-evaluates when secrets are added). pullRequest and scmProvider generators need polling intervals or webhooks to detect new branches.
  • Diff shows no changes but OutOfSync persists. Server-side apply leaves managed-fields annotations that ArgoCD doesn’t understand. Set syncOptions: ServerSideApply=true on the Application or globally so ArgoCD speaks the same dialect as the cluster.
  • Multi-cluster sync slows to a crawl. ArgoCD pulls every cluster’s full resource graph on each reconciliation. For hundreds of Applications, enable argocd.argoproj.io/refresh: hard selectively, shard with argocd-application-controller-shard.repl, and consider a separate ArgoCD instance per cluster group rather than one central controller.

For related Kubernetes deployment and GitOps issues, see Kubernetes CrashLoopBackOff, Kubernetes ImagePullBackOff, Helm not working, and Terraform state lock.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles