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:
| Tool | What it does | Use when |
|---|---|---|
| Sync wave | Applies a group of resources, waits for them to become Healthy, then starts the next wave | Most ordering: CRDs, namespaces, operators, then workloads |
Hook (PreSync, Sync, PostSync, Skip) | Runs a resource (usually a Job) around the sync | One-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.
-1runs before0. - 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
- Everything in wave 0 because someone copied a tutorial. You have no order.
- Huge wave gaps (
0,100,200) with no documentation. Use a published number scheme. - Jobs in waves. A Job that already completed looks Healthy; a retry will not run. That is hook territory.
- 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
ApplyOutOfSyncOnlywhile you are debugging order. - 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.
