Database migrations must be part of your automated deployment pipeline — not a manual step that a developer remembers to run. Automating migrations in CI/CD provides several guarantees: migrations are always applied before the new code that depends on them, failed migrations block the deployment (preventing the app from running against the wrong schema), and the migration state is tracked in version control and verified in tests. This lesson covers the patterns for running Alembic in CI pipelines and Dockerised deployments, including detecting unapplied migrations and handling the challenges of zero-downtime deployments.
Running Migrations in Docker / CI
# ── Dockerfile approach: run migrations before starting the app ───────────────
# docker-entrypoint.sh
#!/bin/bash
set -e
echo "Running database migrations..."
alembic upgrade head
echo "Starting application..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
# docker-compose.yml
version: "3.9"
services:
db:
image: postgres:16
environment:
POSTGRES_USER: blog_app
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: blog_dev
healthcheck:
test: ["CMD-SHELL", "pg_isready -U blog_app"]
interval: 5s
timeout: 5s
retries: 5
api:
build: .
command: ["./docker-entrypoint.sh"]
depends_on:
db:
condition: service_healthy # wait for DB to be ready
environment:
DATABASE_URL: postgresql://blog_app:devpassword@db:5432/blog_dev
SECRET_KEY: dev-secret-key
depends_on: condition: service_healthy in Docker Compose, not just depends_on. A plain depends_on waits for the container to start, not for PostgreSQL to be ready to accept connections. PostgreSQL takes a few seconds to initialise after the container starts. The healthcheck in the example uses pg_isready to verify PostgreSQL is actually accepting connections before the migration runs.alembic check (Alembic 1.9+) to verify that no migrations are missing from the repository without running them. This can be run as a check in pull request pipelines: if a developer added or modified a model without generating a migration, alembic check fails and the PR is blocked. This prevents the common problem of schema drift between the ORM models and the database schema in production.Alembic in GitHub Actions CI
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Check for missing migrations
env:
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
run: |
alembic upgrade head
alembic check # fails if models differ from applied migrations
- name: Run tests
env:
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
run: pytest tests/ -v
FastAPI Startup Migration Check
# app/main.py — Optional: warn if migrations are pending at startup
from contextlib import asynccontextmanager
from fastapi import FastAPI
from alembic.config import Config
from alembic.script import ScriptDirectory
from alembic.runtime.environment import EnvironmentContext
from sqlalchemy import create_engine, text
import logging
logger = logging.getLogger(__name__)
def check_migrations_current(database_url: str) -> bool:
"""Return True if the database is at the latest migration."""
alembic_cfg = Config("alembic.ini")
script = ScriptDirectory.from_config(alembic_cfg)
engine = create_engine(database_url)
with engine.connect() as conn:
try:
result = conn.execute(text("SELECT version_num FROM alembic_version"))
current = result.scalar()
except Exception:
return False
head = script.get_current_head()
return current == head
@asynccontextmanager
async def lifespan(app: FastAPI):
from app.config import settings
if not check_migrations_current(str(settings.database_url)):
logger.warning(
"Database migrations are not up to date! "
"Run: alembic upgrade head"
)
yield
app = FastAPI(lifespan=lifespan)
Zero-Downtime Migration Strategy
Safe zero-downtime migration pattern (expand-contract):
PHASE 1 — EXPAND (backward-compatible, both old and new code work):
Migration: Add new_column as nullable
Application: Code reads from both old_column and new_column
Deploy: Run migration, then deploy new code
PHASE 2 — MIGRATE:
Background job: Backfill new_column from old_column for all existing rows
Monitor: Verify new_column is populated for 100% of rows
PHASE 3 — CONTRACT (new code only):
Migration: Add NOT NULL constraint to new_column
Migration: Drop old_column
Application: Code reads only from new_column
Deploy: Run migration, then deploy code (removes old_column references)
Common Mistakes
Mistake 1 — Not waiting for the database to be ready before running migrations
❌ Wrong — migration fails because DB is not ready yet:
# Runs immediately — PostgreSQL may still be starting
alembic upgrade head && uvicorn app.main:app
✅ Correct — use a health check or retry loop:
until pg_isready -h db -U user; do sleep 1; done
alembic upgrade head # ✓ DB is ready
Mistake 2 — Running migrations from multiple app instances simultaneously
❌ Wrong — two pods both run migrations on startup:
Pod 1: alembic upgrade head — starts applying migration A
Pod 2: alembic upgrade head — sees same pending migration, also tries to apply!
→ Race condition, migration may run twice or fail
✅ Correct — run migrations in a dedicated init-container or job before pods start.
Mistake 3 — Dropping a column while old application code still references it
❌ Wrong — deploy migration and code simultaneously:
Migration drops "body" column + new code uses "content" column
Old code (still running during rolling deploy): tries to read "body" → crashes!
✅ Correct — use expand-contract: add new column first, migrate data, deploy new code, then drop old column in a later deployment.
Quick Reference
| Task | Code / Command |
|---|---|
| Run migrations in Docker | alembic upgrade head in entrypoint before app start |
| Check for missing migrations | alembic check (Alembic 1.9+) |
| Wait for DB in bash | until pg_isready -h db; do sleep 1; done |
| Zero-downtime deploy | Expand-contract pattern (3 phases) |
| Avoid migration races | Run in init-container, not in app startup |