Core idea
Treat migrations as a separate, run-once step that runs before (or alongside) the app rollout and is safe to re-run. Use the migration tool’s locking/versioning so only one job changes the schema.
Typical patterns
- Dedicated migration job (recommended)
CI runs a specific job (Flyway/Liquibase/Prisma/Alembic/etc.) using DB creds from secrets. Gate the deploy on success. - Kubernetes Job / Helm hook
Ship migrations as a discrete container that runs to completion before/with the release (with proper hooks to order it). - App runs migrations on startup
Fine for small apps/single instance; risky for fleets/shared DBs—avoid in most teams.
Example: GitHub Actions — Flyway (PostgreSQL)
name: deploy
on: [push]
jobs:
migrate-db:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Flyway migrations
run: |
docker run --rm \
-v "$PWD/db/migrations:/flyway/sql" \
-e FLYWAY_URL="${{ secrets.DB_URL }}" \
-e FLYWAY_USER="${{ secrets.DB_USER }}" \
-e FLYWAY_PASSWORD="${{ secrets.DB_PASSWORD }}" \
flyway/flyway:10.18.2 migrate
deploy-app:
needs: migrate-db
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# build & deploy your app here
Example: GitLab CI — Liquibase
stages: [migrate, deploy]
migrate_db:
stage: migrate
image: liquibase/liquibase:4.29
script:
- liquibase \
--url="$DB_URL" \
--username="$DB_USER" \
--password="$DB_PASSWORD" \
--changelog-file=changelog/db.changelog-master.yaml \
--log-level=info update
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
DB_URL: $PROD_DB_URL
DB_USER: $PROD_DB_USER
DB_PASSWORD: $PROD_DB_PASSWORD
deploy_app:
stage: deploy
needs: ["migrate_db"]
script:
- ./deploy.sh
Example: Kubernetes Job (works with any tool)
apiVersion: batch/v1
kind: Job
metadata:
name: app-db-migrate
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: flyway/flyway:10.18.2
envFrom:
- secretRef: { name: app-db-credentials }
volumeMounts:
- name: migration-sql
mountPath: /flyway/sql
args: ["migrate"]
volumes:
- name: migration-sql
configMap:
name: app-migrations
Deployment ordering with Helm hooks
# templates/migrate-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: app-db-migrate
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
# ...
Rollbacks & zero-downtime
- Prefer expand/contract:
- Expand: add new tables/cols (nullable/backfilled), write code compatible with old+new.
- Deploy app using only backward-compatible reads/writes.
- Contract: remove old columns in a later release after traffic migration.
- Keep down/rollback scripts only if your tool/team can truly support backward schema downgrades; otherwise use forward-fixes.
- For large data changes: run backfills as separate, resumable jobs (chunked, idempotent) rather than inside schema migrations.
Safety checklist
- Migrations are idempotent and use tool locking (Flyway/Liquibase do this).
- One pipeline is allowed to run migrations per environment (use environments/branches/protected runners).
- DB creds via secrets, never in repo.
- Backups or point-in-time restore configured.
- Wrap DDL in transactions when supported (e.g., Postgres). For MySQL, be careful with non-transactional DDL.
- Add runtime gates: run migrations on staging first; promote to prod on success.
- Monitor: alert on migration failures and long-running locks.
Tool-specific one-liners
- Flyway:
flyway -url=jdbc:postgresql://... -user=... -password=... migrate - Liquibase:
liquibase --url=... --username=... --password=... update - Prisma:
npx prisma migrate deploy - Django:
python manage.py migrate - Rails:
bundle exec rails db:migrate - Alembic (SQLAlchemy):
alembic upgrade head - Knex:
npx knex migrate:latest