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:
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:
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 runrollback.sh, OR - Use Neon's time travel (6h restore window on free tier) to restore the DB, OR
- Pass
--ignore-migration-warningif 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 |