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.
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
.pycfiles (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.
Related articles
- FastAPI FastAPI Deployment with Uvicorn and Gunicorn
Deploy FastAPI to production with Gunicorn managing Uvicorn workers. Cover process counts, timeouts, and health checks.
- Machine Learning ML Model Deployment with FastAPI
Deploy machine learning models as production REST APIs using FastAPI with input validation, async inference, and health checks.
- FastAPI Background Tasks and Celery Integration in FastAPI
Learn to run background tasks in FastAPI using built-in BackgroundTasks and scale with Celery for distributed job processing.
- FastAPI Custom Middleware in FastAPI for Logging, CORS, and Auth
Build custom FastAPI middleware for request logging, CORS handling, authentication checks, and performance monitoring.