Skip to content
Codeloom
FastAPI

Production Deployment of FastAPI with Docker and Gunicorn

Deploy FastAPI to production with Docker, Gunicorn, Uvicorn workers, health checks, multi-stage builds, and best practices.

·6 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • Configuring Gunicorn with Uvicorn workers for FastAPI
  • Writing production-grade Dockerfiles with multi-stage builds
  • Setting up health checks, graceful shutdown, and environment config

Prerequisites

  • Basic FastAPI knowledge
  • Docker fundamentals
  • Command-line comfort with Linux

Why Gunicorn + Uvicorn?

Uvicorn is an ASGI server that runs your FastAPI app, but it is single-process by default. In production, you need multiple worker processes to use all CPU cores and handle concurrent requests. Gunicorn acts as a process manager, spawning and supervising multiple Uvicorn workers.

This combination gives you: multi-process concurrency, automatic worker restart on crashes, graceful shutdown, and a battle-tested production setup.

Gunicorn Configuration

pip install gunicorn uvicorn[standard]

Basic Command

gunicorn app.main:app \
    --workers 4 \
    --worker-class uvicorn.workers.UvicornWorker \
    --bind 0.0.0.0:8000

Configuration File

For production, use a config file instead of command-line flags.

# gunicorn.conf.py
import multiprocessing

# Server socket
bind = "0.0.0.0:8000"

# Worker processes
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "uvicorn.workers.UvicornWorker"
worker_tmp_dir = "/dev/shm"  # Use shared memory for heartbeat

# Timeouts
timeout = 120          # Kill workers silent for 120 seconds
graceful_timeout = 30  # Time for graceful shutdown
keepalive = 5          # Keep-alive connections timeout

# Logging
accesslog = "-"        # Log to stdout
errorlog = "-"         # Log to stderr
loglevel = "info"

# Process naming
proc_name = "fastapi-app"

# Security
limit_request_line = 8190
limit_request_fields = 100
limit_request_field_size = 8190

# Preloading
preload_app = True     # Load app before forking workers (saves memory)

# Server hooks
def on_starting(server):
    print("Gunicorn server starting...")

def post_fork(server, worker):
    print(f"Worker {worker.pid} spawned")

def worker_exit(server, worker):
    print(f"Worker {worker.pid} exited")

Run with:

gunicorn app.main:app -c gunicorn.conf.py

Choosing the Number of Workers

The formula (2 * CPU cores) + 1 is a starting point. For I/O-bound apps (most APIs), this works well. For CPU-heavy workloads, use fewer workers. Monitor and adjust based on actual traffic.

import os

# Dynamic worker count from environment
workers = int(os.getenv("WEB_CONCURRENCY", multiprocessing.cpu_count() * 2 + 1))

The Dockerfile

Multi-Stage Production Build

# Stage 1: Build dependencies
FROM python:3.12-slim AS builder

WORKDIR /build

# Install build tools for packages with C extensions
RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc libpq-dev && \
    rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Production image
FROM python:3.12-slim AS production

# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser

# Install runtime dependencies only
RUN apt-get update && \
    apt-get install -y --no-install-recommends libpq5 curl && \
    rm -rf /var/lib/apt/lists/*

# Copy installed packages from builder
COPY --from=builder /install /usr/local

WORKDIR /app

# Copy application code
COPY app/ ./app/
COPY gunicorn.conf.py .
COPY alembic/ ./alembic/
COPY alembic.ini .

# Set ownership
RUN chown -R appuser:appuser /app

USER appuser

# Environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PORT=8000

EXPOSE 8000

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

# Start with Gunicorn
CMD ["gunicorn", "app.main:app", "-c", "gunicorn.conf.py"]

Key decisions in this Dockerfile:

  • Multi-stage build keeps the final image small by excluding build tools.
  • Non-root user prevents privilege escalation if the container is compromised.
  • PYTHONDONTWRITEBYTECODE avoids writing .pyc files (unnecessary in containers).
  • PYTHONUNBUFFERED ensures log output appears immediately.
  • HEALTHCHECK lets Docker and orchestrators detect unhealthy containers.

Requirements File

# requirements.txt
fastapi==0.115.0
uvicorn[standard]==0.30.0
gunicorn==22.0.0
sqlalchemy[asyncio]==2.0.35
asyncpg==0.29.0
pydantic-settings==2.4.0
alembic==1.13.0

Pin your dependencies to exact versions for reproducible builds.

Health Check Endpoint

from fastapi import FastAPI
from datetime import datetime

app = FastAPI()

@app.get("/health")
async def health_check():
    return {
        "status": "healthy",
        "timestamp": datetime.utcnow().isoformat()
    }

@app.get("/health/ready")
async def readiness_check():
    """Check if the app can serve traffic (DB connected, etc.)."""
    try:
        # Verify database connectivity
        async with get_db() as db:
            await db.execute(text("SELECT 1"))
        return {"status": "ready"}
    except Exception as e:
        return JSONResponse(
            status_code=503,
            content={"status": "not ready", "error": str(e)}
        )

Separate liveness (/health) from readiness (/health/ready). Liveness tells the orchestrator the process is alive. Readiness tells it the app can handle requests (database is connected, migrations are done, etc.).

Environment Configuration

Never hardcode secrets or configuration. Use environment variables with Pydantic Settings.

# app/config.py
from pydantic_settings import BaseSettings
from functools import lru_cache

class Settings(BaseSettings):
    # Application
    app_name: str = "FastAPI App"
    debug: bool = False
    environment: str = "production"

    # Database
    database_url: str
    db_pool_size: int = 20
    db_max_overflow: int = 10

    # Security
    secret_key: str
    access_token_expire_minutes: int = 30
    allowed_origins: list[str] = ["https://app.example.com"]

    # External services
    redis_url: str = "redis://localhost:6379"
    sentry_dsn: str = ""

    model_config = {
        "env_file": ".env",
        "env_file_encoding": "utf-8",
    }

@lru_cache
def get_settings() -> Settings:
    return Settings()
# app/main.py
from app.config import get_settings

settings = get_settings()
app = FastAPI(title=settings.app_name, debug=settings.debug)

Docker Compose for Local Development

# docker-compose.yml
services:
  api:
    build:
      context: .
      target: production
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/app
      - SECRET_KEY=dev-secret-key-change-in-production
      - REDIS_URL=redis://redis:6379
      - ENVIRONMENT=development
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

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

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:

Startup Script with Migrations

Run database migrations before starting the server.

#!/bin/bash
# scripts/start.sh
set -e

echo "Running database migrations..."
alembic upgrade head

echo "Starting Gunicorn..."
exec gunicorn app.main:app -c gunicorn.conf.py

Update the Dockerfile CMD:

COPY scripts/ ./scripts/
RUN chmod +x scripts/start.sh

CMD ["./scripts/start.sh"]

Using exec replaces the shell process with Gunicorn, so signals (SIGTERM for graceful shutdown) reach Gunicorn directly.

Graceful Shutdown

FastAPI’s lifespan events handle cleanup when the server stops.

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup
    print("Starting up: initializing connections")
    app.state.db_pool = await create_pool()
    app.state.redis = await create_redis_connection()
    yield
    # Shutdown
    print("Shutting down: closing connections")
    await app.state.db_pool.close()
    await app.state.redis.close()

app = FastAPI(lifespan=lifespan)

Gunicorn sends SIGTERM to workers during shutdown. The graceful_timeout setting in gunicorn.conf.py controls how long workers have to finish in-flight requests.

Production Checklist

Before deploying, verify these items:

# Disable docs in production
app = FastAPI(
    docs_url="/docs" if settings.debug else None,
    redoc_url="/redoc" if settings.debug else None,
)
  • Debug mode is off
  • API docs are disabled or protected
  • CORS origins are restricted to your domains
  • Secrets come from environment variables, not code
  • Database connection pool is sized for your workload
  • Health check endpoints are implemented
  • Structured logging is configured
  • Error monitoring (Sentry) is connected
  • Docker image runs as non-root
  • Graceful shutdown is handled

Key Takeaways

Production FastAPI deployment centers on Gunicorn managing Uvicorn workers behind a reverse proxy. Use multi-stage Docker builds to keep images small and secure. Configure worker count based on CPU cores, run as a non-root user, and implement both liveness and readiness health checks. Keep configuration in environment variables with Pydantic Settings, run migrations on startup, and handle graceful shutdown through lifespan events. This setup scales from a single container to Kubernetes without architectural changes.