Run Database Migrations Before Deploys with Argo CD Pre-Sync Hooks
Use an Argo CD PreSync hook to run an idempotent database migration Job before your application manifests are applied, so new code never starts against an old schema.
03 Jun 2026, 02:58 UTC

The problem: schema changes must land before the new code
You are adding a microservice (or shipping a new version of one) whose code expects a new column, table, or index. If Argo CD syncs the new Deployment first, pods start crashing or serving errors until someone runs the migration by hand. If you run the migration manually first, you have a gap where the old code meets the new schema.
Argo CD's PreSync hook solves this ordering problem: it runs a Kubernetes Job (your migration) before the rest of the manifests are applied, and the sync only proceeds if the Job succeeds. This guide walks through a working setup and how to verify each stage.
Prerequisites
- Argo CD installed and managing your application (tested behavior applies to the Argo CD 2.x line; check your version's docs for hook specifics).
- An Application with automated sync enabled, or willingness to trigger syncs manually.
- A container image containing your migration tool (e.g., Flyway, Liquibase, Alembic, or a plain script) and access to the database from the cluster.
- An idempotent migration. The hook can be retried, so a migration that fails halfway and re-runs must not corrupt data. Use transactional DDL where the database supports it, or versioned migrations that skip already-applied changes.
- RBAC: the Job's ServiceAccount needs whatever the migration requires (typically just network access to the DB and a Secret with credentials — it does not need broad Kubernetes permissions).
Define the migration Job as a PreSync hook
A resource becomes a hook through the argocd.argoproj.io/hook annotation on that resource itself — not on the Application. Add a Job to the same Git repo (or Helm chart / Kustomize overlay) as your application manifests:
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
backoffLimit: 1
template:
spec:
restartPolicy: Never
serviceAccountName: db-migrator
containers:
- name: migrate
image: registry.example.com/myapp-migrations:1.4.0
args: ["migrate", "up"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: urlTwo details matter here:
hook-delete-policy: BeforeHookCreationdeletes the previous run's Job before creating a new one. Without it, the second sync fails because a Job nameddb-migratealready exists and Jobs are largely immutable. An alternative isHookSucceeded, which cleans up only after success and keeps failed Jobs around for debugging.- The migration image tag should track your release. If the Deployment and the migration Job both reference the same chart value (e.g.,
.Values.image.tagin Helm), the schema version and code version move together.
Because the hook is a normal manifest in your repo, enabling automated sync on the Application is enough — no extra configuration is required:
spec:
syncPolicy:
automated:
prune: true
selfHeal: trueOrder multiple hooks with weights
If you have more than one PreSync hook (say, a schema migration and a cache-warming Job), Argo CD runs them in order of the argocd.argoproj.io/hook-weight annotation, lowest first. Hooks with the same weight run in an unspecified order, so give the migration an explicitly low weight:
argocd.argoproj.io/hook-weight: "-5"Regular (non-hook) resources are applied only after all PreSync hooks succeed.
Check that it worked
After pushing a change, verify each stage rather than just the final state:
- Watch the sync:
argocd app get myapp --watch(run from any machine with the Argo CD CLI configured; requires an Argo CD account with read access to the app). You should see thedb-migrateJob appear with hook phaseRunning, thenSucceeded, before the Deployment updates. - Inspect the migration logs:
kubectl logs -n myapp job/db-migrate(requires get/list on pods and logs in that namespace). Confirm the expected migrations were applied. - Verify the schema directly, e.g.
SELECT version FROM schema_version;against the database, matching the release you deployed. - Confirm the rollout: new pods running the new image tag, readiness probes passing, Application status
SyncedandHealthyin the UI or CLI.
When the hook fails
If the Job fails, Argo CD marks the sync as failed and does not apply the remaining manifests — the old version of the application keeps running against the old schema. That is the safe failure mode you want, but it leaves the release stuck, so:
- Read the hook logs first:
kubectl logs -n myapp job/db-migrate. The most common causes are missing database credentials, network policies blocking the Job, and non-idempotent migrations choking on a partially applied previous attempt. - Fix the cause in Git (or the database), then re-sync. With
BeforeHookCreation, the failed Job is replaced automatically. - If the migration half-applied outside a transaction, restore from backup or manually reconcile the schema before retrying — do not just re-run and hope.
Limitations to plan around
- Long migrations block the sync. A migration that takes 20 minutes holds the whole release for 20 minutes. For very large tables, consider expand-and-contract migrations (backward-compatible changes deployed ahead of time) instead of a blocking hook.
- Hooks run on every sync, not every release. A config-only change also triggers the migration Job. Idempotency makes this harmless but not free.
- Rollback does not undo the database. Argo CD can revert your manifests to the previous Git revision, but it will not run down-migrations. If you roll back code, the old code must tolerate the newer schema — another reason to prefer backward-compatible migrations.
- Hook resources are not pruned by normal sync policy; they are only removed according to their delete policy.
Treat the hook as an ordering guarantee, not a migration framework: keep migrations small, transactional, and backward-compatible, and the PreSync hook becomes a reliable gate between schema and code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.