FastAPI is the fastest-growing Python web framework, chosen by Netflix, Uber, and Microsoft for high-performance APIs. This guide covers everything you need to deploy a FastAPI application to production: a production-optimized Dockerfile, asyncpg for PostgreSQL connections, health check endpoints, CORS configuration, structured logging, and a pre-deployment checklist.
By the end of this guide, your FastAPI application will be containerized, connected to a managed PostgreSQL database, and running on Arxentra with monitoring and auto-restart capabilities.
Project structure
This guide assumes a standard FastAPI project structure:
my-api/
app/
__init__.py
main.py
config.py
models.py
routes/
__init__.py
health.py
users.py
database.py
requirements.txt
Dockerfile
.env
Step 1: Create a production Dockerfile
A production Dockerfile for FastAPI should use multi-stage builds, run as a non-root user, and minimize the final image size.
<h1>Stage 1: Build dependencies</h1>
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
<h1>Stage 2: Production image</h1>
FROM python:3.12-slim
<h1>Security: run as non-root user</h1>
RUN groupadd -r appuser && useradd -r -g appuser appuser
<h1>Copy installed packages from builder</h1>
COPY --from=builder /install /usr/local
WORKDIR /app
COPY app/ ./app/
<h1>Switch to non-root user</h1>
USER appuser
<h1>Expose port</h1>
EXPOSE 8000
<h1>Run with uvicorn</h1>
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--proxy-headers", "--forwarded-allow-ips", "*"]
Key decisions explained:
- Multi-stage build: The builder stage installs dependencies into a separate prefix. The production stage copies only the installed packages, reducing image size by 40-60%.
- Non-root user: Running as root in containers is a security risk. The `appuser` account has no elevated privileges.
- --workers 4: Uvicorn worker count should match the number of vCPUs. For a 2 vCPU container, use 4 workers (2x vCPUs). For a 1 vCPU container, use 2 workers.
- --proxy-headers: Required when running behind a reverse proxy (which Arxentra provides). This ensures `request.client.host` returns the real client IP.
Step 2: Configure asyncpg for PostgreSQL
Use asyncpg for high-performance async PostgreSQL connections. Create a connection pool that manages connections efficiently:
<h1>app/database.py</h1>
import asyncpg
import os
from contextlib import asynccontextmanager
class Database:
def __init__(self):
self.pool = None
async def connect(self):
self.pool = await asyncpg.create_pool(
dsn=os.environ["DATABASE_URL"],
min_size=5,
max_size=20,
max_inactive_connection_lifetime=300,
command_timeout=30,
)
async def disconnect(self):
if self.pool:
await self.pool.close()
@asynccontextmanager
async def acquire(self):
async with self.pool.acquire() as conn:
yield conn
db = Database()
Pool configuration explained:
| Parameter | Value | Reason |
| min_size | 5 | Keeps 5 connections warm to avoid cold-start latency |
| max_size | 20 | Limits total connections to prevent overwhelming PostgreSQL |
| max_inactive_connection_lifetime | 300 | Closes idle connections after 5 minutes to free DB resources |
| command_timeout | 30 | Kills queries running longer than 30 seconds |
For a managed PostgreSQL instance with 100 max connections (Arxentra default), set `max_size` to no more than 20 per worker. With 4 workers, that is 80 connections total, leaving headroom for admin connections and monitoring.
Step 3: Create health check endpoints
Health checks are critical for production deployments. Arxentra uses them to determine if your service is healthy, route traffic only to healthy instances, and automatically restart unhealthy containers.
<h1>app/routes/health.py</h1>
from fastapi import APIRouter, Response
from app.database import db
import time
router = APIRouter()
@router.get("/health")
async def health_check():
"""Basic liveness check. Returns 200 if the process is running."""
return {"status": "ok", "timestamp": time.time()}
@router.get("/health/ready")
async def readiness_check():
"""Readiness check. Returns 200 only if dependencies are available."""
checks = {}
# Check database connection
try:
async with db.acquire() as conn:
await conn.fetchval("SELECT 1")
checks["database"] = "ok"
except Exception as e:
checks["database"] = f"error: {str(e)}"
return Response(
content={"status": "unhealthy", "checks": checks}.__str__(),
status_code=503,
)
return {"status": "ok", "checks": checks}
Two health check endpoints:
Configure these in your Arxentra service settings:
| Setting | Value |
| Liveness path | /health |
| Readiness path | /health/ready |
| Check interval | 30 seconds |
| Timeout | 5 seconds |
| Failure threshold | 3 consecutive failures |
Step 4: Configure CORS
Cross-Origin Resource Sharing (CORS) controls which domains can call your API from a browser. Configure it explicitly for production:
<h1>app/main.py</h1>
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import db
from app.routes import health, users
import os
app = FastAPI(
title="My API",
version="1.0.0",
docs_url="/docs" if os.getenv("ENVIRONMENT") != "production" else None,
redoc_url=None,
)
<h1>CORS configuration</h1>
allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
allow_headers=["Authorization", "Content-Type"],
max_age=86400,
)
@app.on_event("startup")
async def startup():
await db.connect()
@app.on_event("shutdown")
async def shutdown():
await db.disconnect()
app.include_router(health.router)
app.include_router(users.router, prefix="/api/v1")
CORS decisions explained:
- allow_origins: List specific domains. Never use `["*"]` in production if you use cookies or authentication.
- allow_credentials: Set to `True` if your frontend sends cookies or Authorization headers.
- docs_url: Disable Swagger UI in production to avoid exposing your API schema publicly.
- max_age: Cache preflight responses for 24 hours (86400 seconds) to reduce OPTIONS requests.
Set the `ALLOWED_ORIGINS` environment variable in Arxentra:
ALLOWED_ORIGINS=https://yourdomain.com,https://app.yourdomain.com
Step 5: Set up structured logging
Structured JSON logging makes logs searchable and parseable by monitoring tools:
<h1>app/config.py</h1>
import logging
import json
import sys
import time
class JSONFormatter(logging.Formatter):
def format(self, record):
log_entry = {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(record.created)),
"level": record.levelname,
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
}
if record.exc_info:
log_entry["exception"] = self.formatException(record.exc_info)
return json.dumps(log_entry)
def setup_logging():
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JSONFormatter())
root_logger = logging.getLogger()
root_logger.addHandler(handler)
root_logger.setLevel(logging.INFO)
# Reduce noise from third-party libraries
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("asyncpg").setLevel(logging.WARNING)
Call `setup_logging()` at application startup. All logs will be JSON-formatted, making them searchable in Arxentra's log viewer.
Step 6: Deploy to Arxentra
Push your code to GitHub and connect the repository to Arxentra:
DATABASE_URL=postgresql://user:password@host:5432/dbname
ENVIRONMENT=production
ALLOWED_ORIGINS=https://yourdomain.com
If you need a PostgreSQL database, create one from the Arxentra dashboard and link it to your service. The `DATABASE_URL` is automatically injected. See Services Overview.
Step 7: Add request middleware for monitoring
Add middleware to track request duration, status codes, and errors:
<h1>app/middleware.py</h1>
import time
import logging
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
start_time = time.time()
try:
response = await call_next(request)
duration = time.time() - start_time
logger.info(
"request completed",
extra={
"method": request.method,
"path": request.url.path,
"status": response.status_code,
"duration_ms": round(duration * 1000, 2),
"client_ip": request.client.host,
},
)
return response
except Exception as e:
duration = time.time() - start_time
logger.error(
"request failed",
extra={
"method": request.method,
"path": request.url.path,
"error": str(e),
"duration_ms": round(duration * 1000, 2),
},
)
raise
Add this middleware to your FastAPI app:
from app.middleware import RequestLoggingMiddleware
app.add_middleware(RequestLoggingMiddleware)
Production readiness checklist
Before going live, verify every item:
| Category | Item | Status |
| Security | CORS configured with specific origins | Required |
| Security | Running as non-root user in Docker | Required |
| Security | Swagger UI disabled in production | Recommended |
| Security | Input validation on all endpoints (Pydantic) | Required |
| Security | Rate limiting configured | Recommended |
| Database | Connection pool sized correctly | Required |
| Database | Query timeout configured (30s) | Required |
| Database | Migrations applied and tested | Required |
| Health | Liveness endpoint (/health) | Required |
| Health | Readiness endpoint (/health/ready) | Required |
| Logging | Structured JSON logging | Recommended |
| Logging | Request duration logging | Recommended |
| Logging | No sensitive data in logs | Required |
| Performance | Multi-stage Docker build | Recommended |
| Performance | Uvicorn workers = 2x vCPUs | Required |
| Performance | --proxy-headers flag set | Required |
| Deployment | Environment variables set | Required |
| Deployment | Health check configured in platform | Required |
| Deployment | Auto-restart on failure | Required |
Frequently asked questions
Q: How many Uvicorn workers should I use?
Use 2x the number of vCPUs. For a 1 vCPU container: 2 workers. For a 2 vCPU container: 4 workers. For a 4 vCPU container: 8 workers. More workers than this causes context switching overhead that reduces performance. If your application is I/O-bound (most web APIs), this formula works well. For CPU-bound workloads, use 1x vCPUs.
Q: Should I use Gunicorn with Uvicorn?
For Arxentra deployments, using Uvicorn directly with `--workers` is recommended. Gunicorn adds process management, but Arxentra already handles process supervision, auto-restart, and health checking. Adding Gunicorn introduces an extra layer of process management that duplicates platform functionality.
Q: How do I handle database migrations?
Run migrations as a one-off task before deployment. Using the Arxentra CLI: `arx run --command "python -m alembic upgrade head"`. This runs the migration in a temporary container with access to your production database. Never run migrations inside the application startup event -- this causes race conditions when multiple workers start simultaneously.
Q: How do I add rate limiting?
Use the `slowapi` library for FastAPI rate limiting. It supports in-memory and Redis-backed rate limiting. For production, use Redis-backed limits so they persist across container restarts and are shared across replicas.
Q: What about WebSocket support?
FastAPI supports WebSockets natively. Arxentra routes WebSocket connections (wss://) to your containers. No additional configuration is needed. Set the WebSocket path in your router and Arxentra handles the upgrade.
Q: How do I serve static files?
For production APIs, avoid serving static files from FastAPI. Use Arxentra's object storage or a CDN for static assets. If you must serve files from the API (small admin panel, documentation), use `StaticFiles` middleware with caching headers. For a full-stack setup, see Host a SaaS Application.
Q: How do I debug production issues?
Use structured logging (Step 5) combined with Arxentra's log search. Filter logs by level (ERROR), path, or duration. For deeper debugging, use `arx ssh