Skip to content

Deployment

Pipeline

Push to main


GitHub Actions CI
  ├── make check (fmt-check, clippy, test, sqlx-check)
  └── Build Docker images
        ├── mailman-api (--target api)
        └── mailman-worker (--target worker)


Push to ECR (via GitHub OIDC → IAM role)


ECS force-new-deployment (pulls latest image)


Rolling update (new tasks start, old tasks drain)

How It Works

  1. CI runs on every push to main. Tests must pass, clippy must be clean, fmt must be correct.

  2. Docker images are built using the multi-stage Dockerfile with cargo-chef for dependency caching. Two targets: api and worker.

  3. Images are pushed to ECR using GitHub OIDC authentication (no static AWS credentials stored in GitHub).

  4. ECS services update via force-new-deployment. ECS pulls the new image, starts new tasks, and drains old ones once the new tasks are healthy.

  5. Health checks gate the rollout. The ALB health check (GET /health) must pass before the new task receives traffic. If it fails, ECS rolls back automatically.

Database Migrations

Migrations run automatically on service startup via SQLx. The API binary runs pending migrations before accepting traffic.

Safe Migration Checklist

Before writing a migration, check:

Safe?OperationWhy
Add a new tableNo existing queries affected
Add a nullable columnExisting rows get NULL
Add an indexMay be slow on large tables but doesn't break reads
⚠️Add a NOT NULL columnRequires a DEFAULT or backfill — old code doesn't set it
⚠️Rename a columnOld code references the old name — must deploy code first
Drop a columnOld code still SELECT/INSERT it — must remove code first
Drop a tableSame — remove all references first

Two-Phase Migration Pattern

For breaking schema changes, use two deployments:

Phase 1: Add the new column/table, update code to write to both old and new, read from new with fallback to old. Deploy.

Phase 2: Remove the old column/table references from code. Deploy. Then run a migration to drop the old column.

Migration File Naming

NNN_description.sql

Sequential numbering, no gaps. Example: 018_add_organization_id_to_inboxes.sql

Manual Deployment Commands

If you need to deploy manually (outside CI):

bash
# Build and push images
docker build --target api -t $ECR_REPO:api-latest .
docker build --target worker -t $ECR_REPO:worker-latest .
aws ecr get-login-password | docker login --username AWS --password-stdin $ECR_REPO
docker push $ECR_REPO:api-latest
docker push $ECR_REPO:worker-latest

# Force new deployment
aws ecs update-service --cluster mailman --service mailman-api --force-new-deployment
aws ecs update-service --cluster mailman --service mailman-worker --force-new-deployment

# Wait for stability
aws ecs wait services-stable --cluster mailman --services mailman-api mailman-worker

Rollback

ECS automatically rolls back if health checks fail during deployment. For manual rollback:

bash
# List recent task definition revisions
aws ecs list-task-definitions --family mailman-api --sort DESC --max-items 5

# Pin to a previous revision
aws ecs update-service \
  --cluster mailman \
  --service mailman-api \
  --task-definition mailman-api:PREVIOUS_REVISION

Pre-Deployment Checklist

  • [ ] All checks pass locally (make check)
  • [ ] Migrations are backward-compatible (see safe migration checklist)
  • [ ] If adding new config, ensure the env var is set in ECS task definition (Terraform)
  • [ ] If adding new AWS resources, terraform plan shows expected changes
  • [ ] OpenAPI spec updated if API surface changed

SQLx Offline Mode

CI and Docker builds don't have database access. SQLx uses offline mode:

  1. Generate cache: cargo sqlx prepare (writes .sqlx/ directory)
  2. Build with cache: SQLX_OFFLINE=true cargo build

If you add or modify SQL queries, regenerate the cache before pushing:

bash
cargo sqlx prepare -- --workspace

The .sqlx/ directory is committed to the repo.