ArgoCD

Argo CD Sync Waves: Ordering Kubernetes Applies Without Hooks Spaghetti

How sync waves and sync phases actually work, when to use them instead of PreSync hooks, and the failure modes that leave apps stuck OutOfSync.

2026-09-02 · 5 min read

Argo CD applies a Git snapshot to a cluster. That is simple until resource order matters: CRDs before CRs, namespaces before namespaced objects, a database Job before the Deployment that talks to it.

Sync waves are the supported way to express that order. They are also easy to misuse.

Waves vs hooks

Argo CD has two ordering tools:

ToolWhat it doesUse when
Sync waveApplies a group of resources, waits for them to become Healthy, then starts the next waveMost ordering: CRDs, namespaces, operators, then workloads
Hook (PreSync, Sync, PostSync, Skip)Runs a resource (usually a Job) around the syncOne-shot tasks: migrations, smoke tests, cache flush

If you are using a PreSync Job just to “wait for the operator,” you probably wanted a wave.

How waves are numbered

Annotate resources:

metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "0"

Rules that matter in practice:

  • Default wave is 0.
  • Lower numbers run first. -1 runs before 0.
  • Argo CD applies all resources in a wave, then waits until they are Healthy (or the wave timeout hits) before moving on.
  • Waves are evaluated per Application, not globally across the cluster.

A typical platform layout:

-2  CRDs, ClusterRoles
-1  Namespaces, operator Deployments (cert-manager, external-secrets)
 0  Project resources (Certificates, ExternalSecrets)
 1  ConfigMaps / Secrets that apps consume
 2  Deployments, StatefulSets, CronJobs
 3  Ingress / HTTPRoute
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: certificates.cert-manager.io
  annotations:
    argocd.argoproj.io/sync-wave: "-2"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cert-manager
  namespace: cert-manager
  annotations:
    argocd.argoproj.io/sync-wave: "-1"
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: api-tls
  namespace: prod
  annotations:
    argocd.argoproj.io/sync-wave: "0"

If the Certificate lands in the same wave as the CRD, the apply fails with no matches for kind Certificate.

Health is the hidden contract

A wave does not finish when kubectl apply returns. It finishes when Argo CD’s health check says Healthy.

That is why waves stall:

  • A Deployment with no ready replicas (wrong image, missing Secret).
  • A Job that never completes (you put a migration Job in a wave instead of a hook).
  • A CR whose status.conditions never report Ready because the operator is still starting.
  • A PersistentVolumeClaim stuck Pending.

Debug with:

argocd app get my-app --hard-refresh
kubectl -n argocd logs deploy/argocd-application-controller | grep my-app

In the UI, look at the resource that stays Progressing. That resource is the wave gate.

Hooks still have a place

Use a hook when the thing you run should not stay in the cluster as desired state:

apiVersion: batch/v1
kind: Job
metadata:
  name: migrate-schema
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
  ttlSecondsAfterFinished: 300
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ghcr.io/acme/api-migrate:1.19.3

HookSucceeded deletes the Job after success so the next sync can create a fresh one. Without a delete policy you will fight “Job already exists” on every sync.

Do not put a long-lived Deployment in a hook. Hooks are for ephemeral work.

App-of-apps does not inherit waves

A parent Application that points at child Applications has its own waves. Child apps sync independently.

If “the database app must be Healthy before the API app syncs,” waves inside either app will not enforce that. You need one of:

  • a sync wave on the child Application manifests in the parent app
  • an ApplicationSet + explicit sync order (fragile)
  • an external promotion gate (pipeline approves the child path)
# in the parent "root" app
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: postgres
  annotations:
    argocd.argoproj.io/sync-wave: "0"
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: api
  annotations:
    argocd.argoproj.io/sync-wave: "1"

The parent must wait for the child Application object to become Healthy, which requires the child to finish its own sync. Timeouts add up. Budget them.

Common pitfalls

  1. Everything in wave 0 because someone copied a tutorial. You have no order.
  2. Huge wave gaps (0, 100, 200) with no documentation. Use a published number scheme.
  3. Jobs in waves. A Job that already completed looks Healthy; a retry will not run. That is hook territory.
  4. Ignoring prune + waves. Resources in later waves can be pruned while earlier waves are still applying if you prune too aggressively during a failed sync. Prefer ApplyOutOfSyncOnly while you are debugging order.
  5. Custom health checks missing for your CRDs. If Argo CD does not know how to health-check a CR, the wave may proceed too early or never.

A sane default policy

  • CRDs and RBAC: wave -2
  • Operators: wave -1
  • CRs those operators own: wave 0
  • Workloads: wave 2
  • Edge routing: wave 3
  • Migrations and smoke tests: hooks, not waves

If a sync sits on Progressing for more than your wave timeout, fix health — do not add another hook.

Related: Kubernetes GitOps repository layout.

Platform evaluation

See OrchesTerra on your infrastructure

Request access to generate architecture from a repository, review the plan, and run governed reconciliation across AWS, Azure, GCP, and OCI.