Deploy Python FastAPI to Production: Docker, PostgreSQL, Health Checks, and Monitoring

16 min read
fastapi deploypython productiondockerpostgresqlhealth checksmonitoring

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:

ParameterValueReason

min_size5Keeps 5 connections warm to avoid cold-start latency
max_size20Limits total connections to prevent overwhelming PostgreSQL
max_inactive_connection_lifetime300Closes idle connections after 5 minutes to free DB resources
command_timeout30Kills 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:

  • `/health` (liveness): Returns 200 if the process is alive. If this fails, Arxentra restarts the container. Keep this endpoint fast and dependency-free.
  • `/health/ready` (readiness): Returns 200 only if the database is reachable. If this fails, Arxentra stops routing traffic to this instance but does not restart it. This prevents serving requests when dependencies are temporarily unavailable.
  • Configure these in your Arxentra service settings:

    SettingValue

    Liveness path/health
    Readiness path/health/ready
    Check interval30 seconds
    Timeout5 seconds
    Failure threshold3 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:

  • Create a new "Web Application" service in the dashboard
  • Connect your GitHub repository
  • Arxentra detects the Dockerfile automatically
  • Add environment variables:
  • DATABASE_URL=postgresql://user:password@host:5432/dbname
    

    ENVIRONMENT=production

    ALLOWED_ORIGINS=https://yourdomain.com

  • Set health check path to `/health`
  • Click "Deploy"
  • 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:

    CategoryItemStatus

    SecurityCORS configured with specific originsRequired
    SecurityRunning as non-root user in DockerRequired
    SecuritySwagger UI disabled in productionRecommended
    SecurityInput validation on all endpoints (Pydantic)Required
    SecurityRate limiting configuredRecommended
    DatabaseConnection pool sized correctlyRequired
    DatabaseQuery timeout configured (30s)Required
    DatabaseMigrations applied and testedRequired
    HealthLiveness endpoint (/health)Required
    HealthReadiness endpoint (/health/ready)Required
    LoggingStructured JSON loggingRecommended
    LoggingRequest duration loggingRecommended
    LoggingNo sensitive data in logsRequired
    PerformanceMulti-stage Docker buildRecommended
    PerformanceUvicorn workers = 2x vCPUsRequired
    Performance--proxy-headers flag setRequired
    DeploymentEnvironment variables setRequired
    DeploymentHealth check configured in platformRequired
    DeploymentAuto-restart on failureRequired

    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 ` to open a shell in the running container. Integrate Sentry for automatic error tracking with stack traces and context.