Skip to content

teochenglim/aerich-orm-play

Repository files navigation

TortoiseORM + PostgreSQL on Kubernetes

TortoiseORM with asyncpg + aerich migrations, K8s init container pattern.


Architecture

┌─────────────────────────────────────────────────────┐
│  Pod                                                │
│  ┌─────────────────────────────────────────────┐   │
│  │ init container: scripts/entrypoint.sh       │   │
│  │  1. wait for postgres (psql loop)           │   │
│  │  2. aerich table missing? → aerich_check.py │   │
│  │  3. aerich upgrade (apply pending or no-op) │   │
│  │  4. exit 0                                  │   │
│  └──────────────────┬──────────────────────────┘   │
│                     │ only starts after exit 0      │
│  ┌──────────────────▼──────────────────────────┐   │
│  │ app container: uvicorn                      │   │
│  └─────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘

Local setup

cp .env.sample .env   # fill in DB_* vars (defaults work for docker-compose)
uv sync

# Start a throwaway local postgres
docker compose up -d

Stage 1 — First time: initialize aerich (local only)

# Generates migrations/models/0_*_init.py AND applies it to your local DB
uv run aerich init-db

# Verify
uv run aerich upgrade          # → "No upgrade items found" means success

# Commit the generated migration
git add migrations/
git commit -m "init: aerich init-db"

aerich init-db must only ever run locally. It:

  • Generates 0_*_init.py with CREATE TABLE DDL
  • Creates the aerich tracking table in your local DB
  • Records 0_*_init.py as applied

It cannot run in the container — migration files are baked into the image, so aerich sees the migrations/models/ directory already exists and refuses. See The container fresh-DB trick below.


Stage 2 — Model change: generate + verify locally

# 1. Edit app/models.py
#    e.g. bio = fields.TextField(null=True)

# 2. Diff models.py against aerich tracking table → generate migration file
uv run aerich migrate --name "add_user_bio"
# → creates migrations/models/1_*_add_user_bio.py

# 3. Apply locally to verify before committing
uv run aerich upgrade
# → ALTER TABLE "users" ADD COLUMN "bio" TEXT
# → if this fails, fix the migration before committing

# 4. Commit model + migration together
git add app/models.py migrations/
git commit -m "feat: add user bio column"

Never commit a migration you haven't verified locally with aerich upgrade.


Deploy to K8s

docker build -t tortoise-play:latest .
kubectl apply -f k8s/                              # first deploy
kubectl rollout restart deployment/tortoise-app    # subsequent deploys

The init container runs scripts/entrypoint.sh before the app starts:

aerich table exists in postgres?
  NO  → aerich_check.py: CREATE TABLE aerich (empty)
        aerich upgrade:   apply ALL migrations from 0 onward
  YES → (skip aerich_check.py)
        aerich upgrade:   apply any new migrations, or no-op

The container fresh-DB trick

aerich init-db (CLI and Python API) checks whether migrations/models/ exists and raises FileExistsError if it does — which always happens in the container because migration files are baked into the image.

app/aerich_check.py works around this by splitting the two jobs that init-db normally does:

Job Who does it
Create aerich table aerich_check.py via generate_schemas(safe=True) with only aerich.models in scope — no directory operations
Apply migrations 0, 1, 2… aerich upgrade — handles an empty aerich table by applying all files
# app/aerich_check.py (simplified)
config = {
    "connections": TORTOISE_ORM["connections"],
    "apps": {
        "aerich": {
            "models": ["aerich.models"],   # only the tracking model
            "default_connection": "default",
        }
    },
}
await Tortoise.init(config=config)
await Tortoise.generate_schemas(safe=True)   # CREATE TABLE IF NOT EXISTS aerich
await Tortoise.close_connections()
# → aerich upgrade then applies everything from migration 0 onward

What lives where

Local K8s init container
aerich init-db One-time bootstrap Never — use aerich_check.py instead
aerich migrate Generate diff file Never
aerich upgrade Verify before commit Every deploy (no-op if up to date)
Migration files Generated here, committed Baked into image, read-only

Project structure

tortiseorm-play/
├── app/
│   ├── config.py          # assembles DB_URL from DB_* env vars + TORTOISE_ORM dict
│   ├── models.py          # edit this to add/change fields
│   ├── aerich_check.py    # fresh-DB bootstrap for the init container
│   └── main.py            # FastAPI app — generate_schemas=False always
├── migrations/
│   └── models/
│       ├── 0_*_init.py          # generated + committed locally
│       └── 1_*_add_user_bio.py  # generated + committed locally
├── scripts/
│   └── entrypoint.sh      # init container script
├── k8s/
│   ├── 01-postgres.yaml   # Deployment + ClusterIP
│   └── 02-app.yaml        # init container → app + NodePort 30080
├── docker-compose.yml     # local postgres only
├── .env / .env.sample     # DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS
├── Dockerfile             # COPY app/ + COPY migrations/ — both required
└── pyproject.toml         # deps + [tool.aerich] section

Rules

Do Don't
Run aerich upgrade locally before committing Commit migrations you haven't verified
Commit migrations/ and models.py together Commit them separately
Add columns with null=True Add NOT NULL column with no default on an existing table
One concern per migration file Mix unrelated model changes in one migration
Keep generate_schemas=False in register_tortoise Let the app auto-create schema on startup
Run aerich init-db locally once Run aerich init-db inside any container

About

aerich-orm-play

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors