Cybersecurity-Projects/PROJECTS/bug-bounty-platform/docs/research/FASTAPI.md

33 KiB

Production-Grade FastAPI Boilerplate: 2025 Best Practices

The 2025 FastAPI ecosystem has matured significantly, with clear conventions emerging around async-first patterns, Pydantic v2, SQLAlchemy 2.0+, and modern tooling like uv and Ruff. This guide synthesizes the latest practices for building production-grade FastAPI applications following a layered architecture: Models → Repositories → Services → Routes.

Project structure for medium-large applications

The feature-based (domain-driven) organization pattern, popularized by Netflix's Dispatch project, is now the recommended approach for medium-large applications—scaling better than traditional file-type organization.

fastapi-project/
├── alembic/                          # Database migrations
│   ├── versions/
│   └── env.py
├── src/
│   ├── __init__.py
│   ├── main.py                       # FastAPI app, lifespan events
│   ├── core/                         # Shared infrastructure
│   │   ├── __init__.py
│   │   ├── config.py                 # Pydantic Settings
│   │   ├── database.py               # Async session manager
│   │   ├── security.py               # Auth utilities
│   │   ├── exceptions.py             # Global exception classes
│   │   └── dependencies.py           # Shared dependencies
│   ├── auth/                         # Feature module
│   │   ├── __init__.py
│   │   ├── router.py                 # Thin routes
│   │   ├── schemas.py                # Pydantic request/response
│   │   ├── models.py                 # SQLAlchemy models
│   │   ├── repository.py             # DB operations (static methods)
│   │   ├── service.py                # Business logic
│   │   ├── dependencies.py           # Module-specific deps
│   │   ├── constants.py              # Error codes, enums
│   │   └── exceptions.py             # Module exceptions
│   ├── users/
│   │   ├── router.py
│   │   ├── schemas.py
│   │   ├── models.py
│   │   ├── repository.py
│   │   └── service.py
│   └── posts/
│       └── ...
├── tests/
│   ├── conftest.py
│   ├── factories/                    # Test data factories
│   ├── unit/
│   │   ├── services/
│   │   └── repositories/
│   └── integration/
│       └── api/
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
├── nginx/nginx.conf
└── .pre-commit-config.yaml

Key architectural principles include using explicit module imports to prevent circular dependencies (from src.auth import constants as auth_constants), keeping routes thin by delegating all business logic to services, and ensuring repositories handle only database operations without business logic.

Modern pyproject.toml configuration

uv has emerged as the preferred package manager in 2025, developed by Astral (creators of Ruff), offering dramatically faster dependency resolution than Poetry or pip.

[project]
name = "fastapi-app"
version = "1.0.0"
description = "Production FastAPI Application"
requires-python = ">=3.12"

dependencies = [
    "fastapi[standard]>=0.115.0,<1.0.0",
    "pydantic>=2.9.0,<3.0.0",
    "pydantic-settings>=2.6.0,<3.0.0",
    "sqlalchemy>=2.0.0,<3.0.0",
    "alembic>=1.13.0,<2.0.0",
    "asyncpg>=0.29.0,<1.0.0",
    "python-multipart>=0.0.9",
    "pyjwt>=2.9.0",
    "pwdlib[argon2]>=0.2.0",
    "slowapi>=0.1.9",
    "redis>=5.0.0",
    "structlog>=24.0.0",
    "gunicorn>=22.0.0",
    "uvicorn[standard]>=0.30.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0.0",
    "pytest-asyncio>=0.24.0",
    "pytest-cov>=5.0.0",
    "httpx>=0.27.0",
    "factory-boy>=3.3.0",
    "asgi-lifespan>=2.1.0",
    "mypy>=1.13.0",
    "ruff>=0.8.0",
    "pre-commit>=4.0.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.ruff]
target-version = "py312"
line-length = 88
src = ["src"]

[tool.ruff.lint]
select = [
    "E", "W", "F", "I", "B", "C4", "UP", "ARG", 
    "SIM", "TCH", "PTH", "RUF", "ASYNC", "S", "N"
]
ignore = ["E501", "B008", "PLR0913", "S101"]

[tool.ruff.lint.isort]
known-first-party = ["src"]

[tool.mypy]
python_version = "3.12"
strict = true
plugins = ["pydantic.mypy"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
addopts = "-ra -q --cov=src --cov-report=term-missing"

The version pinning strategy uses compatible release ranges (>=2.0.0,<3.0.0) in pyproject.toml while letting lock files (uv.lock) handle exact versions.

Pydantic Settings v2 for configuration management

# src/core/config.py
from functools import lru_cache
from typing import Literal
from pydantic import Field, PostgresDsn, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        extra="ignore",
    )

    # Application
    APP_NAME: str = "FastAPI App"
    ENVIRONMENT: Literal["development", "staging", "production"] = "development"
    DEBUG: bool = False

    # Database
    DATABASE_URL: PostgresDsn
    DB_POOL_SIZE: int = 5
    DB_MAX_OVERFLOW: int = 10

    # Security
    SECRET_KEY: str = Field(..., min_length=32)
    ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
    ALGORITHM: str = "HS256"

    # CORS
    CORS_ORIGINS: list[str] = ["http://localhost:3000"]

    @model_validator(mode="after")
    def validate_production_settings(self) -> "Settings":
        if self.ENVIRONMENT == "production" and self.DEBUG:
            raise ValueError("DEBUG must be False in production")
        return self


@lru_cache
def get_settings() -> Settings:
    return Settings()

settings = get_settings()

Module-specific settings can use env_prefix to namespace environment variables:

# src/auth/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict

class AuthConfig(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="AUTH_", env_file=".env")
    
    JWT_SECRET: str
    JWT_ALG: str = "HS256"
    JWT_EXP: int = 30  # minutes

Async SQLAlchemy 2.0+ with session management

The DatabaseSessionManager pattern provides clean lifecycle management for async database connections:

# src/core/database.py
import contextlib
from typing import AsyncIterator
from sqlalchemy.ext.asyncio import (
    AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
)
from sqlalchemy.pool import AsyncAdaptedQueuePool


class DatabaseSessionManager:
    def __init__(self, url: str, **engine_kwargs):
        self._engine = create_async_engine(
            url,
            poolclass=AsyncAdaptedQueuePool,
            pool_size=5,
            max_overflow=10,
            pool_pre_ping=True,
            echo=False,
            **engine_kwargs
        )
        self._sessionmaker = async_sessionmaker(
            autocommit=False,
            autoflush=False,
            expire_on_commit=False,
            bind=self._engine,
            class_=AsyncSession
        )

    async def close(self):
        await self._engine.dispose()

    @contextlib.asynccontextmanager
    async def session(self) -> AsyncIterator[AsyncSession]:
        session = self._sessionmaker()
        try:
            yield session
        except Exception:
            await session.rollback()
            raise
        finally:
            await session.close()


sessionmanager = DatabaseSessionManager(str(settings.DATABASE_URL))


async def get_db_session() -> AsyncIterator[AsyncSession]:
    async with sessionmanager.session() as session:
        yield session

SQLAlchemy 2.0 models use Mapped type hints with lazy="raise" to prevent implicit lazy loading in async contexts:

# src/users/models.py
from typing import List, Optional
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship, DeclarativeBase


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    hashed_password: Mapped[str] = mapped_column(String(255))
    is_active: Mapped[bool] = mapped_column(default=True)
    full_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
    
    posts: Mapped[List["Post"]] = relationship(
        back_populates="author", 
        lazy="raise",  # Prevents N+1 queries
        cascade="all, delete-orphan"
    )

For relationship loading: use selectinload for collections (one-to-many) and joinedload for single objects (many-to-one).

Repository pattern with static methods

# src/users/repository.py
from typing import Optional, Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from .models import User
from .schemas import UserCreate, UserUpdate


class UserRepository:
    @staticmethod
    async def get_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
        result = await session.execute(select(User).where(User.id == user_id))
        return result.scalars().first()

    @staticmethod
    async def get_by_email(session: AsyncSession, email: str) -> Optional[User]:
        result = await session.execute(select(User).where(User.email == email))
        return result.scalars().first()

    @staticmethod
    async def get_with_posts(session: AsyncSession, user_id: int) -> Optional[User]:
        result = await session.execute(
            select(User)
            .where(User.id == user_id)
            .options(selectinload(User.posts))
        )
        return result.scalars().first()

    @staticmethod
    async def get_multi(
        session: AsyncSession,
        *,
        skip: int = 0,
        limit: int = 100
    ) -> Sequence[User]:
        result = await session.execute(
            select(User).offset(skip).limit(limit)
        )
        return result.scalars().all()

    @staticmethod
    async def create(session: AsyncSession, user_in: UserCreate, hashed_password: str) -> User:
        user = User(
            email=user_in.email,
            hashed_password=hashed_password,
            full_name=user_in.full_name
        )
        session.add(user)
        await session.flush()
        await session.refresh(user)
        return user

    @staticmethod
    async def update(
        session: AsyncSession, user: User, user_in: UserUpdate
    ) -> User:
        update_data = user_in.model_dump(exclude_unset=True)
        for field, value in update_data.items():
            setattr(user, field, value)
        await session.flush()
        await session.refresh(user)
        return user

Service layer with business logic

# src/users/service.py
from sqlalchemy.ext.asyncio import AsyncSession
from .repository import UserRepository
from .schemas import UserCreate, UserUpdate, UserResponse
from .exceptions import UserNotFound, EmailAlreadyExists
from src.core.security import get_password_hash


class UserService:
    @staticmethod
    async def create_user(session: AsyncSession, user_in: UserCreate) -> UserResponse:
        existing = await UserRepository.get_by_email(session, user_in.email)
        if existing:
            raise EmailAlreadyExists(user_in.email)
        
        hashed_password = get_password_hash(user_in.password)
        user = await UserRepository.create(session, user_in, hashed_password)
        await session.commit()
        return UserResponse.model_validate(user)

    @staticmethod
    async def get_user(session: AsyncSession, user_id: int) -> UserResponse:
        user = await UserRepository.get_by_id(session, user_id)
        if not user:
            raise UserNotFound(user_id)
        return UserResponse.model_validate(user)

    @staticmethod
    async def update_user(
        session: AsyncSession, user_id: int, user_in: UserUpdate
    ) -> UserResponse:
        user = await UserRepository.get_by_id(session, user_id)
        if not user:
            raise UserNotFound(user_id)
        
        updated = await UserRepository.update(session, user, user_in)
        await session.commit()
        return UserResponse.model_validate(updated)

Pydantic v2 schemas with validation

# src/users/schemas.py
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, EmailStr, field_validator


class UserBase(BaseModel):
    email: EmailStr
    full_name: str | None = None


class UserCreate(UserBase):
    password: str = Field(..., min_length=8)

    @field_validator("password")
    @classmethod
    def validate_password(cls, v: str) -> str:
        if not any(c.isupper() for c in v):
            raise ValueError("Password must contain at least one uppercase letter")
        if not any(c.isdigit() for c in v):
            raise ValueError("Password must contain at least one digit")
        return v


class UserUpdate(BaseModel):
    email: EmailStr | None = None
    full_name: str | None = None
    is_active: bool | None = None


class UserResponse(UserBase):
    model_config = ConfigDict(from_attributes=True)

    id: int
    is_active: bool
    created_at: datetime

Key Pydantic v2 changes: ConfigDict replaces class Config, from_attributes=True replaces orm_mode, and validators use @field_validator with @classmethod.

Dependency injection patterns

# src/core/dependencies.py
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from .database import get_db_session

# Type alias for cleaner injection
DBSession = Annotated[AsyncSession, Depends(get_db_session)]


# src/auth/dependencies.py
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
import jwt
from src.core.config import settings
from src.core.dependencies import DBSession
from src.users.repository import UserRepository
from src.users.models import User

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token")


async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)],
    db: DBSession
) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
        user_id: int = payload.get("sub")
        if user_id is None:
            raise credentials_exception
    except jwt.InvalidTokenError:
        raise credentials_exception
    
    user = await UserRepository.get_by_id(db, user_id)
    if user is None:
        raise credentials_exception
    return user


async def get_current_active_user(
    current_user: Annotated[User, Depends(get_current_user)]
) -> User:
    if not current_user.is_active:
        raise HTTPException(status_code=400, detail="Inactive user")
    return current_user


CurrentUser = Annotated[User, Depends(get_current_active_user)]

Exception handling with global handlers

# src/core/exceptions.py
class BaseAppException(Exception):
    def __init__(self, message: str, status_code: int = 500):
        self.message = message
        self.status_code = status_code
        super().__init__(self.message)


class ResourceNotFound(BaseAppException):
    def __init__(self, resource: str, identifier: str | int):
        super().__init__(f"{resource} {identifier} not found", status_code=404)


class ConflictError(BaseAppException):
    def __init__(self, message: str):
        super().__init__(message, status_code=409)


# src/users/exceptions.py
from src.core.exceptions import ResourceNotFound, ConflictError

class UserNotFound(ResourceNotFound):
    def __init__(self, user_id: int):
        super().__init__("User", user_id)

class EmailAlreadyExists(ConflictError):
    def __init__(self, email: str):
        super().__init__(f"Email {email} already registered")
# src/main.py - Exception handlers
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from src.core.exceptions import BaseAppException

app = FastAPI()


@app.exception_handler(BaseAppException)
async def app_exception_handler(request: Request, exc: BaseAppException):
    return JSONResponse(
        status_code=exc.status_code,
        content={"detail": exc.message, "type": exc.__class__.__name__}
    )


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    return JSONResponse(
        status_code=422,
        content={"detail": "Validation Error", "errors": exc.errors()}
    )

JWT authentication with PyJWT and Argon2

PyJWT is now the recommended library over python-jose, and pwdlib with Argon2 is the modern choice for password hashing:

# src/core/security.py
from datetime import datetime, timedelta, timezone
import jwt
from pwdlib import PasswordHash
from src.core.config import settings

password_hash = PasswordHash.recommended()


def verify_password(plain_password: str, hashed_password: str) -> bool:
    return password_hash.verify(plain_password, hashed_password)


def get_password_hash(password: str) -> str:
    return password_hash.hash(password)


def create_access_token(subject: int | str, expires_delta: timedelta | None = None) -> str:
    expire = datetime.now(timezone.utc) + (
        expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
    )
    to_encode = {"sub": str(subject), "exp": expire}
    return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)


def create_refresh_token(subject: int | str) -> str:
    expire = datetime.now(timezone.utc) + timedelta(days=30)
    to_encode = {"sub": str(subject), "exp": expire, "type": "refresh"}
    return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)

SlowAPI rate limiting integration

# src/core/rate_limit.py
from slowapi import Limiter
from slowapi.util import get_remote_address
from src.core.config import settings


def get_user_identifier(request) -> str:
    """Rate limit by user ID if authenticated, otherwise by IP."""
    auth_header = request.headers.get("Authorization")
    if auth_header and auth_header.startswith("Bearer "):
        try:
            import jwt
            token = auth_header.split(" ")[1]
            payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
            return f"user:{payload.get('sub')}"
        except Exception:
            pass
    return get_remote_address(request)


limiter = Limiter(
    key_func=get_user_identifier,
    storage_uri=str(settings.REDIS_URL) if settings.REDIS_URL else None,
    default_limits=["100/hour", "10/minute"],
    headers_enabled=True,
    in_memory_fallback_enabled=True,
)
# src/auth/router.py
from fastapi import APIRouter, Request
from src.core.rate_limit import limiter

router = APIRouter(prefix="/auth", tags=["auth"])


@router.post("/token")
@limiter.limit("5/minute")  # Stricter limit for auth endpoints
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
    ...

Thin routes calling services

# src/users/router.py
from fastapi import APIRouter, status
from src.core.dependencies import DBSession
from src.auth.dependencies import CurrentUser
from .service import UserService
from .schemas import UserCreate, UserUpdate, UserResponse

router = APIRouter(prefix="/users", tags=["users"])


@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(user_in: UserCreate, db: DBSession):
    return await UserService.create_user(db, user_in)


@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: CurrentUser):
    return UserResponse.model_validate(current_user)


@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: int, db: DBSession, current_user: CurrentUser):
    return await UserService.get_user(db, user_id)


@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(user_id: int, user_in: UserUpdate, db: DBSession, current_user: CurrentUser):
    return await UserService.update_user(db, user_id, user_in)

Main application assembly

# src/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded

from src.core.config import settings
from src.core.database import sessionmanager
from src.core.rate_limit import limiter
from src.users.router import router as users_router
from src.auth.router import router as auth_router


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup
    yield
    # Shutdown
    await sessionmanager.close()


app_config = {"title": settings.APP_NAME, "version": "1.0.0"}
if settings.ENVIRONMENT == "production":
    app_config["openapi_url"] = None  # Hide docs in production

app = FastAPI(**app_config, lifespan=lifespan)

# Middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.CORS_ORIGINS,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Rate limiting
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

# Routers
app.include_router(auth_router, prefix="/api/v1")
app.include_router(users_router, prefix="/api/v1")


@app.get("/health")
async def health_check():
    return {"status": "healthy"}

Multi-stage Dockerfile for production

# Build stage
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y gcc && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade -r requirements.txt

# Runtime stage
FROM python:3.12-slim
WORKDIR /app

RUN useradd --create-home --shell /bin/bash app && chown -R app:app /app

COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY --chown=app:app ./src /app/src

USER app

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PYTHONPATH="/app"

HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

CMD ["gunicorn", "src.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000"]

Docker Compose configuration

version: '3.8'

services:
  app:
    build: .
    ports:
      - "8000:8000"
    env_file: .env.production
    environment:
      - DATABASE_URL=postgresql+asyncpg://user:password@db:5432/myapp
      - REDIS_URL=redis://:redis_pass@redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped
    networks:
      - backend

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - backend

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes --requirepass redis_pass
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "redis_pass", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - backend

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - app
    networks:
      - backend

volumes:
  postgres_data:
  redis_data:

networks:
  backend:

Nginx reverse proxy configuration

upstream fastapi_backend {
    least_conn;
    server app:8000 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/s;

server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com;

    ssl_certificate /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    add_header X-Frame-Options DENY always;
    add_header X-Content-Type-Options nosniff always;
    add_header Strict-Transport-Security "max-age=63072000" always;

    client_max_body_size 10M;

    location /api/ {
        limit_req zone=api burst=20 nodelay;
        
        proxy_pass http://fastapi_backend;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Connection "";
    }

    location /api/auth/ {
        limit_req zone=auth burst=5 nodelay;
        proxy_pass http://fastapi_backend;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /health {
        proxy_pass http://fastapi_backend;
    }
}

Pytest configuration and fixtures

# tests/conftest.py
import pytest
from typing import AsyncGenerator, Generator
from httpx import ASGITransport, AsyncClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool

from src.main import app
from src.core.database import get_db_session
from src.core.security import create_access_token
from src.users.models import Base
from tests.factories.user import UserFactory

SQLALCHEMY_TEST_URL = "sqlite:///:memory:"

engine = create_engine(
    SQLALCHEMY_TEST_URL,
    connect_args={"check_same_thread": False},
    poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


@pytest.fixture(scope="session", autouse=True)
def setup_database():
    Base.metadata.create_all(bind=engine)
    yield
    Base.metadata.drop_all(bind=engine)


@pytest.fixture
def db() -> Generator[Session, None, None]:
    connection = engine.connect()
    transaction = connection.begin()
    session = TestingSessionLocal(bind=connection)
    
    yield session
    
    session.close()
    transaction.rollback()
    connection.close()


@pytest.fixture
def client(db: Session) -> Generator:
    def override_get_db():
        yield db

    app.dependency_overrides[get_db_session] = override_get_db
    
    with TestClient(app) as test_client:
        yield test_client
    
    app.dependency_overrides.clear()


@pytest.fixture
async def async_client(db: Session) -> AsyncGenerator[AsyncClient, None]:
    def override_get_db():
        yield db

    app.dependency_overrides[get_db_session] = override_get_db
    
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test"
    ) as ac:
        yield ac
    
    app.dependency_overrides.clear()


@pytest.fixture
def test_user(db: Session):
    return UserFactory()


@pytest.fixture
def authenticated_client(client, test_user):
    token = create_access_token(test_user.id)
    client.headers.update({"Authorization": f"Bearer {token}"})
    yield client
    client.headers.clear()

GitHub Actions CI/CD workflow

name: CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip
      
      - name: Install dependencies
        run: |
          pip install --upgrade pip
          pip install -r requirements.txt
          pip install -r requirements-dev.txt          
      
      - name: Run Ruff
        run: |
          ruff check --output-format=github .
          ruff format --check .          
      
      - name: Run MyPy
        run: mypy src/
      
      - name: Run tests
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/test_db
        run: pytest --cov=src --cov-report=xml
      
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: coverage.xml

  build-and-push:
    needs: lint-and-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Pre-commit hooks configuration

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: check-yaml
      - id: check-toml
      - id: end-of-file-fixer
      - id: trailing-whitespace
      - id: check-merge-conflict
      - id: detect-private-key

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.8.6
    hooks:
      - id: ruff-check
        args: [--fix, --exit-non-zero-on-fix]
      - id: ruff-format

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.13.0
    hooks:
      - id: mypy
        additional_dependencies: [pydantic>=2.0, types-python-dateutil]
        args: [--config-file=pyproject.toml]
        exclude: ^tests/

  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.22.1
    hooks:
      - id: gitleaks

Structured logging with correlation IDs

# src/core/logging.py
import structlog
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request

shared_processors = [
    structlog.contextvars.merge_contextvars,
    structlog.stdlib.add_log_level,
    structlog.processors.TimeStamper(fmt="iso"),
    structlog.processors.StackInfoRenderer(),
]


def configure_logging(environment: str):
    if environment == "production":
        renderer = structlog.processors.JSONRenderer()
    else:
        renderer = structlog.dev.ConsoleRenderer(colors=True)

    structlog.configure(
        processors=shared_processors + [renderer],
        wrapper_class=structlog.stdlib.BoundLogger,
        cache_logger_on_first_use=True,
    )


class CorrelationIdMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        correlation_id = request.headers.get("X-Correlation-ID", str(uuid.uuid4()))
        
        structlog.contextvars.clear_contextvars()
        structlog.contextvars.bind_contextvars(
            correlation_id=correlation_id,
            method=request.method,
            path=request.url.path,
        )
        
        response = await call_next(request)
        response.headers["X-Correlation-ID"] = correlation_id
        return response

Conclusion

The 2025 FastAPI ecosystem emphasizes async-first patterns with SQLAlchemy 2.0+'s mature async support and proper relationship loading strategies. Ruff has consolidated the linting ecosystem, replacing black, isort, and flake8 with a single, faster tool. uv offers significantly faster dependency management than pip or Poetry.

Key architectural takeaways include maintaining thin routes that delegate to services, repositories that handle only database operations without business logic, and leveraging FastAPI's dependency injection with Annotated types for cleaner code. For security, PyJWT and Argon2 (via pwdlib) are the current recommended choices.

Production deployments benefit from multi-stage Docker builds with non-root users, Nginx as a reverse proxy with rate limiting at multiple layers, and structured logging with correlation IDs for distributed tracing. The testing stack centers on pytest-asyncio with asyncio_mode="auto" and httpx's AsyncClient with ASGITransport for async endpoint testing.