A coin-tracker FastAPI app, refactored four times across four git branches — from the messy 417-line main.py everyone writes first, to async SQLAlchemy + Postgres with refresh-token rotation. The diffs between branches are the lesson. One command to run the whole thing locally: docker compose up.
You're on
main— the production tier. Pick where to start:
Branch What you learn tier-0-originalThe realistic "before" — single 417-line main.pytier-1-resource-basedResource-based routers, REST verbs, Pydantic response models tier-2-layeredSQLAlchemy 2.0, Alembic, repository + service layers main=tier-3-productionAsync SQLAlchemy + Postgres, refresh tokens, rate limiting, Railway
Tier 1 was organize. Tier 2 was layer. Tier 3 is make it production-grade. The goal is a service you would not be embarrassed to run for real users — and the lessons are the things tutorials usually skip.
Every layer is now async/await:
# repositories/user.py
async def get_by_email(self, email: str) -> User | None:
result = await self.db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
# services/auth.py
async def authenticate(self, email: str, password: str) -> TokenPair: ...
# routers/auth.py
@router.post("/login")
async def login(...): ...The DB engine is create_async_engine, the session is AsyncSession, and the request lifecycle is async end-to-end. Why does this matter? Concurrency under load. A sync FastAPI worker blocks on every DB call; an async worker frees the event loop to serve other requests during I/O waits. Same hardware, several times the throughput.
DATABASE_URL is the single knob:
| Environment | URL |
|---|---|
| Local dev | sqlite+aiosqlite:///./tracker_coin.db |
| Railway / prod | postgresql+asyncpg://... (Railway sets this when you attach a Postgres add-on) |
The app normalizes Heroku/Railway-style postgres:// → postgresql+asyncpg:// automatically (app/config.py:_normalize_database_url). Tests still use SQLite for speed; the production deploy uses Postgres without code changes.
The same Alembic migrations run against both — no separate "sqlite migration" and "postgres migration."
This is the auth feature most tutorials skip. Tier 3 issues two tokens per session:
| Token | What it is | Lifetime | Stored where |
|---|---|---|---|
| Access | JWT, stateless | 15 min | Client memory |
| Refresh | Random opaque string, rotated on use | 14 days | Client + refresh_tokens table (hashed) |
Flow:
POST /auth/registeror/auth/login→ returns{access_token, refresh_token}- When access expires, client calls
POST /auth/refreshwith the refresh token - Server revokes the old refresh token and issues a fresh pair (access + refresh)
POST /auth/logoutrevokes the refresh token (logout now actually does something on the server)
Why rotate? If a refresh token leaks, an attacker can use it once — but then either the user's next refresh fails (detection signal) or the attacker keeps refreshing and the user's next refresh fails. Either way, you find out. Without rotation, a leaked refresh token is good for 14 days, silently.
We store sha256(token) not the raw value — so a DB read does not yield usable tokens.
Tier 1 swapped CoinCap for CoinGecko in a single file. Tier 3 introduces a Protocol so swapping is one config change:
# providers/base.py
class PriceProvider(Protocol):
name: str
async def fetch_market_coins(self) -> list[MarketCoin]: ...
# providers/factory.py
def get_price_provider() -> PriceProvider:
if settings.COINCAP_API_KEY:
return CoinCapProvider() # paid, more reliable
return CoinGeckoProvider() # keyless, free tierThe service receives a PriceProvider via dependency injection; it does not know or care which one. Adding a third provider (CryptoCompare, Binance, etc.) is one new file.
Brute-force is the #1 way poorly-deployed APIs get owned. Tier 3 adds slowapi per-IP limits:
| Endpoint | Limit |
|---|---|
POST /auth/register |
3 / minute |
POST /auth/login |
5 / minute |
POST /auth/refresh |
10 / minute |
Limits are skipped in tests via RATE_LIMIT_ENABLED=false so the suite stays fast and deterministic.
The headline of tier 3 is async + Postgres. We make that runnable with one command locally — no Python install required, no paid hosting needed:
docker compose up --buildThat spins up a Postgres container and the FastAPI app side by side, runs Alembic migrations, and serves the API on http://localhost:8000. Files involved:
Dockerfile—python:3.12-slim, installs deps, runsalembic upgrade head && uvicorn ...docker-compose.yml—db(postgres:16-alpine) +app, wired together with a healthcheck so the app waits for the DB.dockerignore— keeps the build context lean
Optional production deploy reference (kept, not required):
Procfile—releaserunsalembic upgrade head,webboots uvicorn on$PORTrailway.json— health check at/health, restart policyruntime.txt— pins Python 3.12
These last three are example PaaS deploy config (Railway / Heroku-style). They show what shipping this app to a managed platform would look like, but they're not required to use the project.
app/
├── rate_limit.py # NEW: slowapi Limiter
├── providers/
│ ├── base.py # NEW: PriceProvider Protocol + MarketCoin
│ ├── factory.py # NEW: pick provider by config
│ ├── coingecko.py # async (httpx.AsyncClient)
│ └── coincap.py # NEW: alt provider, requires API key
├── models/
│ └── refresh_token.py # NEW
├── repositories/
│ └── refresh_token.py # NEW
├── services/auth.py # rewritten: refresh + rotation + logout-revoke
├── schemas/auth.py # + RefreshRequest, LogoutRequest, refresh_token field
├── routers/auth.py # + /auth/refresh; rate-limited
├── main.py # + slowapi middleware + 429 handler
└── db.py # async engine + AsyncSession
alembic/versions/
└── 0002_refresh_tokens.py # NEW
Dockerfile # NEW (one-command local dev)
docker-compose.yml # NEW (app + Postgres)
.dockerignore # NEW
Procfile # NEW (optional PaaS deploy reference)
railway.json # NEW (optional PaaS deploy reference)
runtime.txt # NEW (Python 3.12)
pytest.ini # NEW (asyncio_mode = auto)
One command. No Python install, no Postgres install, no paid hosting:
docker compose up --buildThis boots a Postgres container and the FastAPI app together, runs Alembic migrations, and serves on http://localhost:8000. Open http://localhost:8000/docs for the interactive Swagger UI.
Run the test suite inside the container:
docker compose run --rm app pytest -v18 async tests, all green. Includes refresh-token rotation, revocation, and rate-limit-disabled coverage.
Tear down (data persists in a named volume):
docker compose down # stop containers, keep data
docker compose down -v # stop AND wipe Postgres volumeIf you'd rather not use Docker, the app runs fine on a local Python interpreter against SQLite:
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # default DATABASE_URL is sqlite+aiosqlite
alembic upgrade head # apply migrations to local sqlite
uvicorn app.main:app --reload
pytest -v # in another terminalSame code, same tests, just a different DB dialect — that's the dual-DB story tier 3 is teaching.
# Populate coins (uses CoinGecko by default; CoinCap if COINCAP_API_KEY is set)
curl -X POST http://localhost:8000/coins/refresh
# Register — returns access_token + refresh_token
curl -X POST http://localhost:8000/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"secret123","password_confirmation":"secret123"}'
ACCESS=... # from response
REFRESH=...
# Use access token
curl -X POST http://localhost:8000/portfolio \
-H "Authorization: Bearer $ACCESS" \
-H 'Content-Type: application/json' \
-d '{"coin_id":1}'
# Rotate when access expires (15 min)
curl -X POST http://localhost:8000/auth/refresh \
-H 'Content-Type: application/json' \
-d "{\"refresh_token\":\"$REFRESH\"}"
# Logout — actually revokes the refresh token server-side
curl -X POST http://localhost:8000/auth/logout \
-H 'Content-Type: application/json' \
-d "{\"refresh_token\":\"$REFRESH\"}"-
Why store refresh tokens hashed? A database read should not be a security incident. With SHA-256 hashing, an attacker who dumps the table cannot replay sessions. Same logic as password hashing.
-
Why JWT for access, opaque for refresh? JWTs are great for stateless verification (every request) and bad for revocation (you can't take one back). Refresh tokens need server-side state for revocation, so they are opaque random strings backed by a row in
refresh_tokens. Use the right tool for each purpose. -
Why async if SQLite is sync underneath?
aiosqliteruns SQLite operations on a thread pool; the FastAPI handler still gets to await without blocking. In production you swap toasyncpg(truly async) and gain real throughput. Same code path. -
Why a Protocol instead of an abstract base class? PEP 544 structural typing —
CoinGeckoProviderandCoinCapProviderdon't need to inherit from anything. Any class with the right shape is aPriceProvider. Easier to mock in tests, easier to add new providers without touching base classes. -
Why does
/auth/logoutneed the refresh token? Because logout that does nothing on the server (tier 2's behavior) is a lie. Revoking the token is the difference between "we told the client to forget" and "this session is actually over." -
Why the
release: alembic upgrade headstep? Schema changes must run before the new code starts handling traffic. Putting migrations in the release phase guarantees this — the new web process only boots after the DB is on the right version. -
Why does the test suite still work without Postgres? Because the abstraction is the dialect, not the engine. SQLAlchemy 2.0 + Alembic generate dialect-appropriate SQL automatically. We test the contract; production exercises the dialect-specific paths.
- Tier 0 → 1: separate concerns, fix REST verbs, add response models, swap a dead provider
- Tier 1 → 2: introduce ORM, migrations, repos, services, domain exceptions
- Tier 2 → 3: go async, add Postgres (via
docker compose), rotate refresh tokens, rate-limit auth, abstract providers, ship a deployable container
Same coin tracker. Same external API contract (one additive change: refresh_token in tier 3 responses). Three branches' worth of progressively production-grade lessons.
If this is your first FastAPI codebase, diff the branches in order — that is the lesson. The code is just the artifact.