FastAPI Production Deployment - 2025 Complete Guide
Deploying FastAPI applications to production requires careful consideration of performance, security, monitoring, and scalability. This guide covers the essential practices for robust production deployments.
1. Production-Ready Dockerfile
# Multi-stage build for optimized production image
FROM python:3.12-slim as builder
# Install system dependencies
RUN apt-get update && apt-get install -y \
build-essential \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install UV for fast dependency management
RUN pip install uv
# Set working directory
WORKDIR /app
# Copy dependency files
COPY pyproject.toml uv.lock ./
# Install dependencies
RUN uv sync --frozen --no-dev
# Production stage
FROM python:3.12-slim as production
# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy virtual environment from builder
COPY --from=builder /app/.venv /app/.venv
# Set environment variables
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONPATH="/app"
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# Create app directory
WORKDIR /app
# Copy application code
COPY --chown=appuser:appuser . .
# Switch to non-root user
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Expose port
EXPOSE 8000
# Run with Gunicorn
CMD ["gunicorn", "main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]
2. Production Configuration
# config/production.py
import os
from pydantic import BaseSettings
class ProductionSettings(BaseSettings):
# Database
DATABASE_URL: str = os.getenv("DATABASE_URL")
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 30
# Security
SECRET_KEY: str = os.getenv("SECRET_KEY")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
CORS_ORIGINS: list[str] = ["https://yourdomain.com"]
# Server
DEBUG: bool = False
LOG_LEVEL: str = "INFO"
WORKERS: int = 4
# Redis
REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379")
# Monitoring
SENTRY_DSN: str = os.getenv("SENTRY_DSN", "")
# Email
SMTP_HOST: str = os.getenv("SMTP_HOST")
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
SMTP_USERNAME: str = os.getenv("SMTP_USERNAME")
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD")
class Config:
env_file = ".env.production"
settings = ProductionSettings()
3. Gunicorn Configuration
# gunicorn.conf.py
import multiprocessing
import os
# Server socket
bind = "0.0.0.0:8000"
backlog = 2048
# Worker processes
workers = int(os.getenv("WORKERS", multiprocessing.cpu_count() * 2 + 1))
worker_class = "uvicorn.workers.UvicornWorker"
worker_connections = 1000
max_requests = 1000
max_requests_jitter = 50
# Restart workers after this many requests, to help prevent memory leaks
max_requests = 1000
max_requests_jitter = 100
# Timeout for graceful workers restart
timeout = 30
keepalive = 5
# Logging
accesslog = "-"
errorlog = "-"
loglevel = os.getenv("LOG_LEVEL", "info")
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
# Process naming
proc_name = "fastapi-app"
# Server mechanics
preload_app = True
daemon = False
pidfile = "/tmp/gunicorn.pid"
user = os.getenv("USER", "appuser")
group = os.getenv("GROUP", "appuser")
tmp_upload_dir = None
# SSL (if terminating SSL at the application level)
# keyfile = "/path/to/keyfile"
# certfile = "/path/to/certfile"
def when_ready(server):
server.log.info("Server is ready. Spawning workers")
def worker_int(worker):
worker.log.info("worker received INT or QUIT signal")
def pre_fork(server, worker):
server.log.info("Worker spawned (pid: %s)", worker.pid)
def pre_exec(server):
server.log.info("Forked child, re-executing.")
def post_fork(server, worker):
server.log.info("Worker spawned (pid: %s)", worker.pid)
def post_worker_init(worker):
worker.log.info("Worker initialized (pid: %s)", worker.pid)
def worker_abort(worker):
worker.log.info("Worker aborted (pid: %s)", worker.pid)
4. Nginx Configuration
# nginx.conf
upstream fastapi_backend {
least_conn;
server app1:8000 max_fails=3 fail_timeout=30s;
server app2:8000 max_fails=3 fail_timeout=30s;
server app3:8000 max_fails=3 fail_timeout=30s;
}
# Rate limiting
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 www.yourdomain.com;
# Redirect HTTP to HTTPS
location / {
return 301 https://$server_name$request_uri;
}
}
server {
listen 443 ssl http2;
server_name yourdomain.com www.yourdomain.com;
# SSL Configuration
ssl_certificate /etc/ssl/certs/yourdomain.com.crt;
ssl_certificate_key /etc/ssl/private/yourdomain.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'";
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Main application
location / {
limit_req zone=api burst=20 nodelay;
proxy_pass http://fastapi_backend;
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;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
# Authentication endpoints with stricter rate limiting
location ~ ^/(auth|login|register) {
limit_req zone=auth burst=5 nodelay;
proxy_pass http://fastapi_backend;
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;
}
# Static files (if serving from nginx)
location /static/ {
alias /var/www/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Health check
location /health {
proxy_pass http://fastapi_backend;
access_log off;
}
}
5. Docker Compose for Production
# docker-compose.prod.yml
version: '3.8'
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/ssl:ro
- static_files:/var/www/static:ro
depends_on:
- app
restart: unless-stopped
networks:
- web
app:
build:
context: .
dockerfile: Dockerfile
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- SECRET_KEY=${SECRET_KEY}
- SENTRY_DSN=${SENTRY_DSN}
depends_on:
- postgres
- redis
restart: unless-stopped
deploy:
replicas: 3
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
networks:
- web
- backend
volumes:
- static_files:/app/static
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
restart: unless-stopped
networks:
- backend
deploy:
resources:
limits:
cpus: '1.0'
memory: 1G
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
restart: unless-stopped
networks:
- backend
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/etc/prometheus/console_libraries'
- '--web.console.templates=/etc/prometheus/consoles'
networks:
- monitoring
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
volumes:
- grafana_data:/var/lib/grafana
networks:
- monitoring
volumes:
postgres_data:
redis_data:
prometheus_data:
grafana_data:
static_files:
networks:
web:
driver: bridge
backend:
driver: bridge
monitoring:
driver: bridge
6. Health Checks and Monitoring
# app/health.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db import get_db
import redis
import psutil
import time
router = APIRouter()
@router.get("/health")
async def health_check():
"""Basic health check"""
return {"status": "healthy", "timestamp": time.time()}
@router.get("/health/detailed")
async def detailed_health_check(db: Session = Depends(get_db)):
"""Detailed health check with dependencies"""
health_status = {
"status": "healthy",
"timestamp": time.time(),
"checks": {}
}
# Database check
try:
db.execute("SELECT 1")
health_status["checks"]["database"] = {"status": "healthy"}
except Exception as e:
health_status["checks"]["database"] = {"status": "unhealthy", "error": str(e)}
health_status["status"] = "unhealthy"
# Redis check
try:
r = redis.Redis.from_url(settings.REDIS_URL)
r.ping()
health_status["checks"]["redis"] = {"status": "healthy"}
except Exception as e:
health_status["checks"]["redis"] = {"status": "unhealthy", "error": str(e)}
health_status["status"] = "unhealthy"
# System resources
cpu_usage = psutil.cpu_percent(interval=1)
memory = psutil.virtual_memory()
disk = psutil.disk_usage('/')
health_status["checks"]["system"] = {
"cpu_usage_percent": cpu_usage,
"memory_usage_percent": memory.percent,
"disk_usage_percent": (disk.used / disk.total) * 100,
"status": "healthy" if cpu_usage < 90 and memory.percent < 90 else "warning"
}
if health_status["status"] == "unhealthy":
raise HTTPException(status_code=503, detail=health_status)
return health_status
7. Logging Configuration
# app/logging_config.py
import logging
import sys
from pythonjsonlogger import jsonlogger
def setup_logging():
"""Configure structured logging for production"""
# Create custom formatter
formatter = jsonlogger.JsonFormatter(
fmt='%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Configure root logger
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
# Console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
# Set levels for third-party loggers
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("uvicorn.error").setLevel(logging.INFO)
return root_logger
# Usage in main.py
import logging
from app.logging_config import setup_logging
logger = setup_logging()
@app.middleware("http")
async def log_requests(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time
logger.info(
"Request processed",
extra={
"method": request.method,
"url": str(request.url),
"status_code": response.status_code,
"process_time": process_time,
"user_agent": request.headers.get("user-agent"),
"client_ip": request.client.host
}
)
return response
8. CI/CD Pipeline
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install uv
uv sync
- name: Run tests
run: uv run pytest --cov=app --cov-report=xml
- name: Security scan
run: |
uv run bandit -r app/
uv run safety check
build-and-deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build -t ${{ secrets.DOCKER_REGISTRY }}/myapp:${{ github.sha }} .
docker tag ${{ secrets.DOCKER_REGISTRY }}/myapp:${{ github.sha }} ${{ secrets.DOCKER_REGISTRY }}/myapp:latest
- name: Push to registry
run: |
echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
docker push ${{ secrets.DOCKER_REGISTRY }}/myapp:${{ github.sha }}
docker push ${{ secrets.DOCKER_REGISTRY }}/myapp:latest
- name: Deploy to production
uses: appleboy/[email protected]
with:
host: ${{ secrets.PRODUCTION_HOST }}
username: ${{ secrets.PRODUCTION_USER }}
key: ${{ secrets.PRODUCTION_SSH_KEY }}
script: |
cd /opt/myapp
docker-compose pull
docker-compose up -d --remove-orphans
docker system prune -f
9. Environment Variables
# .env.production
# Database
DATABASE_URL=postgresql://user:password@postgres:5432/myapp
POSTGRES_DB=myapp
POSTGRES_USER=myapp_user
POSTGRES_PASSWORD=secure_password
# Redis
REDIS_URL=redis://:redis_password@redis:6379
REDIS_PASSWORD=secure_redis_password
# Security
SECRET_KEY=your-super-secure-secret-key-here
CORS_ORIGINS=["https://yourdomain.com"]
# Monitoring
SENTRY_DSN=https://your-sentry-dsn-here
GRAFANA_PASSWORD=secure_grafana_password
# Email
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password
# Application
DEBUG=false
LOG_LEVEL=INFO
WORKERS=4
10. Scaling Considerations
Horizontal Scaling
# Scale application containers
docker-compose up -d --scale app=5
# Use load balancer health checks
# Add to nginx upstream configuration
server app4:8000 max_fails=3 fail_timeout=30s;
server app5:8000 max_fails=3 fail_timeout=30s;
Database Optimization
# Connection pooling
from sqlalchemy import create_engine
from sqlalchemy.pool import QueuePool
engine = create_engine(
DATABASE_URL,
poolclass=QueuePool,
pool_size=20,
max_overflow=30,
pool_pre_ping=True,
pool_recycle=3600
)
Caching Strategy
# Redis caching
import redis
from functools import wraps
redis_client = redis.Redis.from_url(settings.REDIS_URL)
def cache_result(expiration: int = 300):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
cache_key = f"{func.__name__}:{hash(str(args) + str(kwargs))}"
# Try to get from cache
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# Execute function and cache result
result = await func(*args, **kwargs)
redis_client.setex(cache_key, expiration, json.dumps(result))
return result
return wrapper
return decorator
Security Best Practices
- HTTPS Only: Always use SSL/TLS in production
- Rate Limiting: Implement rate limiting on all endpoints
- Input Validation: Validate all input data with Pydantic
- Authentication: Use secure JWT tokens with proper expiration
- CORS: Configure CORS properly for your frontend domains
- Security Headers: Add security headers via nginx
- Secrets Management: Never store secrets in code or images
- Regular Updates: Keep all dependencies updated
- Monitoring: Monitor for security incidents and anomalies
- Backups: Implement automated database backups