2026-04-11 | PreviewProof Team

Preview-Ready FastAPI + Postgres: A Working Async Python Stack

fastapipreview environmentsalembicasync pythonsqlalchemy

FastAPI is increasingly the default Python web framework for new projects. Async-first, Pydantic-typed, openapi.json out of the box. The tradeoff against Django is that you’re building more of the application yourself — there’s no migration system, no admin, no built-in auth. That tradeoff also shapes what preview-readiness looks like, because the gotchas are different.

The big ones: async DB connection pooling needs preview-aware lifecycle management; Alembic’s migration story differs from Django’s in subtle but important ways; FastAPI’s dependency injection makes env var propagation cleaner than most frameworks but only if you wire it correctly; and the question of what runs background jobs is open in a way it isn’t for Django (Celery is the default) or Rails (Sidekiq is the default).

This post covers the configuration. Reference stack: FastAPI on Uvicorn, Postgres 16, SQLAlchemy 2.0 async, Alembic for migrations.

docker-compose.yml: local development

docker-compose.yml
services:
api:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/app
ENV: development
volumes:
- .:/code
depends_on:
db: { condition: service_healthy }
db:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5

docker-compose.yml: preview-ready

Once the stack moves to ephemeral previews, the same docker-compose.yml shifts to use built images, env-var-injected hostnames, and preview-specific settings:

docker-compose.yml
services:
api:
image: ghcr.io/myorg/myapp:${GIT_SHA}
command: ./bin/preview-entrypoint.sh
environment:
ENV: preview
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/app_preview
ALEMBIC_DATABASE_URL: postgresql://postgres:postgres@db:5432/app_preview
JWT_SECRET: ${JWT_SECRET}
ALLOWED_HOSTS: ${PREVIEW_HOST}
CORS_ORIGINS: https://${PREVIEW_HOST}

ALEMBIC_DATABASE_URL uses the synchronous driver (postgresql://, not postgresql+asyncpg://). Alembic’s CLI runs synchronously and will fail if you point it at an async URL. The application code uses the async URL. Two URLs, same database — one of the small frustrations of running async SQLAlchemy with Alembic.

The entrypoint

bin/preview-entrypoint.sh
#!/usr/bin/env bash
set -e
echo "==> Migrating"
alembic upgrade head
echo "==> Seeding"
python -m app.seed
echo "==> Booting"
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2

alembic upgrade head is idempotent and safe to run on every boot. Two workers is reasonable for previews — uvicorn workers don’t share DB pools, so worker count multiplies pool size. Keep it small.

Async DB lifecycle

The most common FastAPI preview bug isn’t a configuration bug — it’s an async lifecycle bug that doesn’t reproduce locally because local development uses --reload, which sidesteps it. The pattern below ties pool creation and disposal to the application lifespan correctly.

app/db.py
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
import os
engine = create_async_engine(
os.environ["DATABASE_URL"],
pool_size=5,
max_overflow=5,
pool_pre_ping=True, # critical for preview environments
pool_recycle=300,
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
@asynccontextmanager
async def lifespan(app):
yield
await engine.dispose()
app/main.py
from fastapi import FastAPI
from app.db import lifespan
app = FastAPI(lifespan=lifespan)

pool_pre_ping=True matters more in previews than in production. Preview database containers can be paused, restarted, or replaced unexpectedly. Without pre_ping, a stale connection in the pool causes the first request after a database restart to fail. With it, the connection is validated before each use. Trivial cost, massive reliability win.

pool_recycle=300 recycles connections every 5 minutes. Preview databases sometimes drop idle connections after short timeouts (especially behind some managed-Postgres proxies). 300 seconds is conservative.

Dependency injection for env vars

The FastAPI-native way to handle config is pydantic-settings, injected as a dependency.

app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
class Settings(BaseSettings):
env: str = "development"
database_url: str
jwt_secret: str
allowed_hosts: list[str] = ["*"]
cors_origins: list[str] = []
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@lru_cache
def get_settings() -> Settings:
return Settings()
app/routes.py
from fastapi import Depends
from app.config import Settings, get_settings
@app.get("/health")
async def health(settings: Settings = Depends(get_settings)):
return {"env": settings.env}

The lru_cache on get_settings() means the settings object is constructed once per process — no repeated env-var parsing per request. In tests, app.dependency_overrides[get_settings] lets you swap the settings cleanly.

The list-typed env vars (ALLOWED_HOSTS, CORS_ORIGINS) parse comma-separated strings out of the box with pydantic-settings. So ALLOWED_HOSTS=pr-123.previews.example.com,localhost becomes a Python list automatically.

Seed strategy

A FastAPI seed module that reuses the application’s SQLAlchemy session pattern:

app/seed.py
import asyncio
from sqlalchemy import select
from app.db import SessionLocal
from app.models import User, Project
from app.security import hash_password
async def seed():
async with SessionLocal() as db:
existing = await db.scalar(select(User).where(User.email == "[email protected]"))
if existing:
return # idempotent
admin = User(
name="Test Admin",
password_hash=hash_password("preview123"),
role="admin",
)
db.add(admin)
await db.flush()
for i in range(5):
db.add(Project(name=f"Project {i+1}", owner_id=admin.id))
await db.commit()
if __name__ == "__main__":
asyncio.run(seed())

For richer data, use factory_boy with the async factory boy ext or build factories on top of SQLAlchemy directly. See seeding Postgres for preview environments.

Alembic specifics

Alembic’s migration graph is more linear than Django’s, which means fewer “ordering” gotchas. The two that do show up:

Async Alembic. If you’ve configured Alembic for async, your env.py looks different from the default. Make sure run_migrations_online() uses engine.begin() with the async engine and that the offline path still works for autogeneration on the developer’s laptop. Most teams who hit issues here end up with two env.py files — one async for migrate, one sync for autogenerate — which is fine.

Schema-only autogenerate is brittle. alembic revision --autogenerate should never run in CI or in a preview entrypoint. It’s a developer-time tool. The preview entrypoint runs alembic upgrade head only.

See database migration patterns for preview environments for the broader story.

Background jobs: when does FastAPI need more?

FastAPI ships with BackgroundTasks for fire-and-forget work that runs in the same process. It works for sending an email after a request, kicking off a non-critical sync job. It does not work for retries, scheduled jobs, or work that needs to survive a request lifecycle.

When you outgrow BackgroundTasks:

  • Celery is the heavyweight option. Mature, battle-tested, requires a broker.
  • Dramatiq is lighter. Same shape as Celery, simpler.
  • RQ is the simplest — Redis-only, very small surface area.
  • arq is async-native and fits FastAPI codebases especially well.

For preview environments, prefer arq or RQ over Celery — fewer moving parts, less to scope per-PR. See background jobs in ephemeral preview environments for the broader pattern.

Env template

.env.preview.example
ENV=preview
DATABASE_URL= # postgresql+asyncpg://...
ALEMBIC_DATABASE_URL= # postgresql://... (sync driver)
JWT_SECRET= # generated per preview
ALLOWED_HOSTS= # injected: pr-123.previews.example.com
CORS_ORIGINS= # injected: https://pr-123.previews.example.com

FastAPI rewards being explicit. Async lifecycle, pool_pre_ping, an idempotent seed, an Alembic config that doesn’t try to autogenerate in CI — that’s most of what makes previews stable. The configuration above works with whatever preview tool or self-hosted setup you bring.

If you’d rather not handle the per-PR orchestration yourself, PreviewProof runs Compose-shaped FastAPI stacks like the one above and handles dynamic hostname injection into pydantic-settings, so CORS_ORIGINS resolves correctly per PR. It’s most useful for teams where reviewer sign-off and an audit trail are part of the delivery process — the configuration here doesn’t change, but the verification layer on top is what makes it a fit.