Alembic in CI/CD — Automated Migrations in Deployment

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
Note: Always run migrations with 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.
Tip: In CI pipelines, use 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.
Warning: In zero-downtime deployments (blue-green, rolling updates), the new application version and the old version run simultaneously for a brief period. During this window, the new code may expect schema changes (new columns) that the old code does not know about, or vice versa. Always write backward-compatible migrations: add new columns as nullable (old code ignores them), never rename or drop columns while old code is running (old code breaks). Only drop old columns in a separate deployment after the old code is completely gone.

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

🧠 Test Yourself

Your FastAPI app deploys with 3 Kubernetes pods. Each pod runs alembic upgrade head on startup. Why is this a problem and what is the fix?