Gating Argo CD Syncs with PreSync Hooks for Database Migrations
PreSync hooks let you block an Argo CD sync until a Kubernetes Job succeeds. Here is how to wire that up for database migrations, plus the cleanup and RBAC trade-offs to watch.
26 May 2026, 11:46 UTC

The deployment race you cannot see
You merge a pull request. CI builds an image, updates the Git manifest, and Argo CD syncs it to production. Minutes later, pods start crash-looping. The new code expects a database column that does not exist yet, because the schema migration ran in a separate CI job that finished thirty seconds too late. The outage is not caused by bad code; it is caused by ordering.
Argo CD PreSync resource hooks close that gap. They let you attach a short-lived Kubernetes Job to an Application so the sync pauses until the Job succeeds. If the Job fails, the rest of the sync does not proceed.
What a PreSync hook actually does
Argo CD recognizes special annotations on any resource in your Application source. Adding argocd.argoproj.io/hook: PreSync tells the controller to treat that resource as a hook rather than a normal sync target. For namespaced resources, the hook runs in the Application's destination namespace. The controller waits for the hook to reach a completed state before it starts syncing the remaining resources.
This is built into the open-source Argo CD controller. You do not need a separate operator, a webhook, or an external CI runner to gate a sync on a database migration.
Worked example: a migration Job before a Deployment
Imagine an Application that deploys a web service. The service needs a new subscriptions table before the new image starts. You can place both resources in the same Git repository and let Argo CD order them.
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate-2026-11
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: myregistry/db-tools:1.4.0
command: ["./migrate", "up"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-db-credentials
key: url
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: myregistry/web:2.1.0
ports:
- containerPort: 8080
When Argo CD syncs this Application, it creates the Job first. The controller watches the Job and does not create the Deployment until the Job reports success. If the migration command exits non-zero and the Job exhausts its backoffLimit, the sync stops and the Deployment is left untouched.
Checking the result
Run the following from a terminal with cluster access and permissions to read Applications and Jobs in the target namespace:
kubectl get application <app-name> -n <argocd-namespace> -o yaml | grep -A 10 'hooks:'
kubectl get job db-migrate-2026-11 -n <target-namespace>
Expected check: the Application status shows the PreSync hook as Succeeded before the Deployment appears. If the hook failed, the Job remains (unless a delete policy removes it) and the Deployment is absent.
Cleanup and ordering
Finished Jobs do not disappear on their own. The argocd.argoproj.io/hook-delete-policy annotation controls what happens after the hook runs. Common values include:
| Policy | Behavior |
|---|---|
HookSucceeded | Delete the hook resource only after it completes successfully. |
HookFailed | Delete the hook resource only after it fails. |
BeforeHookCreation | Delete any previous hook with the same name before creating a new one. |
If you omit the annotation, the Job stays in the namespace. Over many deploys, leftover Jobs accumulate and make it harder to tell which migration ran last. Setting HookSucceeded keeps the namespace clean, but it also removes the evidence of success. A common compromise is to use a unique Job name per migration and combine HookSucceeded with BeforeHookCreation so retries replace the old object.
When you need multiple pre-sync steps, add argocd.argoproj.io/sync-wave to each hook. Lower numbers run first. The same annotation works on regular resources, so you can interleave hooks and Deployments in a single wave sequence.
Limitations to plan around
PreSync hooks are not a general-purpose job scheduler. Three constraints matter in practice:
- Namespace RBAC. The Job runs with the default ServiceAccount in the target namespace unless you specify another one. If your migration needs cluster-scoped permissions—say, to create a new CRD or modify a ClusterRole—the hook will fail with a forbidden error. You must either grant the namespace ServiceAccount those rights (which may be broader than you want) or split the work into a separate cluster-scoped process.
- Short-lived work only. Resource hooks are designed for one-time initialization. Long-running sidecars, operators, or controllers do not belong here; Argo CD expects the hook to terminate.
- Version drift. Hook annotation behavior has evolved across Argo CD releases. Before relying on a specific delete policy or wave interaction, check the release notes for your installed version and test in a non-production cluster.
Start small and verify
If you have never used PreSync hooks, begin with a read-only migration—a Job that runs SELECT 1 or a dry-run schema check. Annotate it with PreSync and HookSucceeded, sync the Application, and watch the hook phase in the Argo CD UI. Then deliberately break the Job command and sync again to confirm the sync aborts and your Deployment does not roll out. Once you trust that behavior, swap in the real migration.
The payoff is a deployment pipeline where schema and code move together, and the Git repository remains the single source of truth for both.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.