Skip to content

Migrations

Database schema is managed by Alembic. In development, CREATE_TABLES_ON_STARTUP=true auto-creates tables via SQLAlchemy for convenience. In production, Alembic is the only way to modify the schema — the app will refuse to start if CREATE_TABLES_ON_STARTUP=true in production.

Running Migrations

# Apply all pending migrations
uv run alembic upgrade head

# Check current revision
uv run alembic current

# Show migration history
uv run alembic history

In Docker:

# Production: via the migrate service in docker-compose.prod.yml
# (runs as a one-shot container using the same backend image)
docker compose -f docker-compose.prod.yml --env-file .env run --rm migrate

# deploy.sh runs this automatically before restarting services
./scripts/deployment/deploy.sh

# Or inside a running container (ad-hoc)
docker compose -f docker-compose.prod.yml exec web alembic -c /app/alembic.ini upgrade head

Creating Migrations

After modifying SQLAlchemy models:

# Auto-generate migration from model changes
uv run alembic revision --autogenerate -m "add widget table"

# Review the generated migration in migrations/versions/
# Then apply it
uv run alembic upgrade head

Always review auto-generated migrations — Alembic doesn't always get complex changes right (renames, data migrations, column type changes).

Pre-Launch vs Post-Launch

Pre-launch (before real users exist), it's often cleaner to edit the initial migration (migrations/versions/2dfe8856ab4b_initial_schema.py) in place rather than stack corrective migrations on top. Staging is ephemeral — drop the DB and re-run alembic upgrade head to pick up the edit. Keeps the migration history clean for launch.

Post-launch (production has users), migrations are append-only. NEVER edit a migration that's already been applied to a production DB — it violates Alembic's version-tracking contract and will break alembic upgrade head for anyone on an older revision. Generate a new migration with alembic revision --autogenerate and let it accumulate in migrations/versions/.

The trigger for switching modes is the first production deploy with real users, not any particular date.

Production Deploy Flow

First deploy

Run first-deploy.sh on the server after cloning the repo and creating .env:

./scripts/deployment/first-deploy.sh

It does: pull images → alembic upgrade head (creates all tables) → seed_all.py (tiers, admin, Stripe products) → docker compose up -d → health check.

Subsequent deploys

deploy.sh runs migrations BEFORE restarting services -- if the migration fails, old code keeps running on old schema:

./scripts/deployment/deploy.sh

CD does this automatically on push to staging or main.

The production security validator enforces CREATE_TABLES_ON_STARTUP=false — the app will fail to start in production if it's set to true.

Development vs Production

Development Production
Schema creation CREATE_TABLES_ON_STARTUP=true (SQLAlchemy create_all) alembic upgrade head
Schema changes Modify model → restart app (auto-creates) Modify model → alembic revision --autogenerate → deploy migration
Seed data seed_all.py via Docker Compose seed service seed_all.py (one-time, detects Alembic and skips create_tables)
Rollback Drop and recreate (dev data is disposable) alembic downgrade -1

Downgrading

# Downgrade one revision (locally)
uv run alembic downgrade -1

# Downgrade to specific revision
uv run alembic downgrade abc123

# Downgrade all the way
uv run alembic downgrade base

# On production: via the migrate service, overriding the default command
docker compose -f docker-compose.prod.yml --env-file .env run --rm migrate alembic downgrade -1

Rolling back after a failed deploy

rollback.sh pulls an older image and restarts containers, but it does not automatically roll back migrations. Each backend image carries a sapari.alembic_head label; rollback.sh compares it to the live DB's current version:

  • Same head: safe rollback, no migration involved.
  • Different head: script aborts with a migration mismatch warning. Either:
  • Manually alembic downgrade <old-head> first, then run rollback.sh, OR
  • Use Neon's time travel (6h restore window on free tier) to restore the DB, OR
  • Pass --ignore-migration-warning if you're confident the migration was backwards-compatible (e.g., added a nullable column that old code simply doesn't read).

The safest pattern is the two-deploy rule: destructive migrations (drop column, rename) ship in a separate deploy from the code change, after the code is already running without using that schema element.

Production Safety

The migrations/env.py has a production safety check. When ENVIRONMENT=production, it requires CONFIRM_PRODUCTION_MIGRATION=yes to proceed. This prevents accidental migration runs against production.

Key Files

Component Location
Alembic config backend/alembic.ini
Migration env backend/migrations/env.py
Versions backend/migrations/versions/
Migrate service docker-compose.prod.yml (profile: migrate)
Alembic head image label backend/Dockerfile (LABEL sapari.alembic_head)
Rollback safety check scripts/deployment/rollback.sh

← Monitoring Home →