Running schema migrations as Jobs
Tested with: Kubernetes 1.31 · Helm 3.16 · Argo CD 2.12 · Prisma 6 · Flyway 10 · Alembic 1.13
Schema migrations should run once, before the new application version receives traffic, against the direct endpoint (not the pooled one: migrations use locks and session state). A Job with the same Secret and CA mount as the application does that.
The Job
Section titled “The Job”apiVersion: batch/v1kind: Jobmetadata: name: migrate-1.4.2 namespace: app annotations: helm.sh/hook: pre-upgrade,pre-install helm.sh/hook-weight: "0" helm.sh/hook-delete-policy: before-hook-creation,hook-succeededspec: backoffLimit: 2 activeDeadlineSeconds: 900 template: spec: restartPolicy: Never containers: - name: migrate image: ghcr.io/acme/api:1.4.2 command: ["npx", "prisma", "migrate", "deploy"] env: - name: DATABASE_URL valueFrom: { secretKeyRef: { name: zb-pg-prod, key: DATABASE_URL } } volumeMounts: - { name: databasezy-ca, mountPath: /etc/databasezy, readOnly: true } volumes: - name: databasezy-ca configMap: { name: databasezy-ca }DATABASE_URL from the ExternalSecret template already carries sslmode=verify-full&sslrootcert=/etc/databasezy/ca.crt.
apiVersion: batch/v1kind: Jobmetadata: name: migrate-1.4.2 namespace: app annotations: helm.sh/hook: pre-upgrade,pre-install helm.sh/hook-delete-policy: before-hook-creation,hook-succeededspec: backoffLimit: 2 template: spec: restartPolicy: Never containers: - name: flyway image: flyway/flyway:10 args: ["migrate"] env: - name: FLYWAY_URL value: jdbc:postgresql://$(PGHOST):5432/app?sslmode=verify-full&sslrootcert=/etc/databasezy/ca.crt - name: PGHOST valueFrom: { secretKeyRef: { name: zb-pg-prod, key: host } } - name: FLYWAY_USER valueFrom: { secretKeyRef: { name: zb-pg-prod, key: username } } - name: FLYWAY_PASSWORD valueFrom: { secretKeyRef: { name: zb-pg-prod, key: password } } volumeMounts: - { name: sql, mountPath: /flyway/sql, readOnly: true } - { name: databasezy-ca, mountPath: /etc/databasezy, readOnly: true } volumes: - name: sql configMap: { name: migrations-sql } - name: databasezy-ca configMap: { name: databasezy-ca }Same Job; only the command changes:
# Alembiccommand: ["alembic", "upgrade", "head"]# Djangocommand: ["python", "manage.py", "migrate", "--noinput"]# Railscommand: ["bin/rails", "db:migrate"]# golang-migratecommand: ["migrate", "-path", "/migrations", "-database", "$(DATABASE_URL)", "up"]Ordering with Helm and Argo CD
Section titled “Ordering with Helm and Argo CD”- Helm: the
helm.sh/hook: pre-upgradeannotation above runs the Job before the Deployment changes; the upgrade fails if the Job fails. - Argo CD: use
argocd.argoproj.io/hook: PreSyncandargocd.argoproj.io/hook-delete-policy: BeforeHookCreationinstead (Argo ignores Helm hooks when rendering charts through its own Helm support unless enabled). See Argo CD and Helm patterns. - Give the Job a name that changes per release (
migrate-{{ .Chart.AppVersion }}) so it always runs, and ahook-delete-policyso successful Jobs do not pile up.
Practical settings
Section titled “Practical settings”- Point at the direct endpoint; the pooler does not support
LOCK, advisory locks or sessionSET. - Set
activeDeadlineSecondsso a stuck lock cannot block a deploy forever. - Databasezy takes a backup before resizes and version upgrades, not before your migrations. For risky migrations,
create a manual snapshot first:
zb backups create pg-7f3k --label pre-1.4.2. - The Job needs the same egress NetworkPolicy allowance as the app; label it accordingly (
app: api) or add a selector forjob-name.