| |

Docker 81 🐳 Containerizing Python, FastAPI, and Django Applications with Gunicorn/Uvicorn

⏱️ Estimated read: 15 min | |

A Python web application in a container is only as good as its process model. Running uvicorn main:app inside a Docker image works for development, but it leaves every CPU core beyond the first idle and provides no process supervision when a worker crashes. The production pattern pairs a process manager (Gunicorn) with an async worker class (UvicornWorker) so that multiple processes accept connections in parallel while each process uses an event loop to handle concurrent I/O . For Django, the equivalent pairing is Gunicorn with synchronous workers, since Django’s request/response cycle is synchronous by default unless ASGI is explicitly configured.

Docker’s official guides for FastAPI and Django both converge on a multi-stage build: a builder stage installs dependencies into a virtual environment or user directory, and a slim runtime stage copies only the installed packages and application code . This separation keeps build tools out of the final image, reducing size and attack surface. The Docker Hardened Images (DHI) used in recent guides go further by running as non-root by default and omitting shells and package managers from runtime images .

This chapter covers the production Dockerfile pattern for FastAPI with Gunicorn and UvicornWorker, the Django equivalent with Gunicorn’s sync workers, the worker-count trade-off between CPU-bound and I/O-bound workloads, and the container-specific pitfalls that cause worker crashes, lost logs, and silent OOM kills. It assumes familiarity with Docker fundamentals and the distinction between WSGI and ASGI.

Key point: Gunicorn is the process manager; UvicornWorker is the async engine inside each worker. The worker count is not (2 × CPU) + 1 by default—that formula assumes synchronous WSGI. For async FastAPI workloads, fewer workers with more concurrency per worker often outperform a large worker pool, and memory limits decide the ceiling.


Why containerized Python needs a process manager

Uvicorn alone is single-process. A single Uvicorn process uses one CPU core. On a four-core container, three cores sit idle while the single event loop handles every connection . Gunicorn solves this by forking multiple worker processes, each with its own event loop, and distributing incoming connections across them. The UvicornWorker class bridges the two: Gunicorn manages the process lifecycle (start, restart, graceful shutdown, timeout), and Uvicorn handles the async I/O within each worker.

Gunicorn’s sync workers are wrong for FastAPI. FastAPI is an ASGI application. Gunicorn’s default worker class is synchronous WSGI. Passing a FastAPI app to a sync worker either fails or forces the framework into a blocking compatibility mode. The uvicorn.workers.UvicornWorker class is mandatory for async FastAPI . Django can use Gunicorn’s default sync workers unless it is configured as an ASGI application with channels; in that case, UvicornWorker is also required.

The worker-count formula is workload-dependent. Gunicorn’s documentation recommends (2 × CPU) + 1 workers for synchronous WSGI applications . FastAPI’s async model changes the calculus. A single Uvicorn worker can hold thousands of concurrent connections in its event loop while waiting on I/O. For pure I/O-bound workloads (database queries, HTTP calls to upstream services), fewer workers—sometimes equal to the CPU count or less—suffice. For CPU-bound work (synchronous computation inside request handlers), more workers are needed, and the work should be pushed to a thread pool to avoid blocking the event loop .

Memory is the real constraint. Each Gunicorn worker is a separate process with its own copy of the application, its own connection pools, and—if the application loads machine learning models or large caches—its own copy of that memory . Four workers with a 500MB model each consume 2GB before handling a single request. The worker count must be validated against the container’s memory limit, not just its CPU count. Docker’s OOM killer sends SIGKILL to the process, which Gunicorn cannot catch; the log shows a worker “Booting” with no preceding error because the kill signal was silent .

The trade-off. A multi-stage Dockerfile with Gunicorn and UvicornWorker produces a production image that uses all allocated CPU cores, survives individual worker crashes through Gunicorn’s supervision, and serves async I/O concurrently. The cost is complexity: two configuration layers (Gunicorn and Uvicorn), a worker count that must be tuned to the workload and memory budget, and a build pattern that separates dependency installation from runtime execution.


a. The FastAPI Dockerfile: multi-stage build with Gunicorn and UvicornWorker

The Dockerfile follows a consistent pattern across official and community guides: build dependencies in a first stage, copy artifacts to a minimal runtime stage, run as a non-root user, and launch Gunicorn with the UvicornWorker class.

# syntax=docker/dockerfile:1

# Builder stage: install dependencies with build tools available.
FROM python:3.12-slim AS builder

WORKDIR /app

# Install dependencies into a user-local directory.
# Using --user avoids needing a virtual environment copy step.
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt

# Runtime stage: minimal image, no build tools.
FROM python:3.12-slim

WORKDIR /app

# Copy installed packages from builder.
COPY --from=builder /root/.local /root/.local

# Copy application code.
COPY . .

# Non-root user for security.
RUN useradd --create-home --shell /bin/bash appuser
USER appuser

# Ensure user-local binaries are on PATH.
ENV PATH=/root/.local/bin:$PATH
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

EXPOSE 8000

# Gunicorn manages workers; UvicornWorker provides async I/O.
CMD ["gunicorn", "main:app", \
     "--workers", "4", \
     "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--bind", "0.0.0.0:8000", \
     "--timeout", "120", \
     "--access-logfile", "-", \
     "--error-logfile", "-"]

The PYTHONUNBUFFERED=1 environment variable is not cosmetic. Without it, Python buffers stdout and stderr, and container logs appear only when the buffer fills or the process exits. For a server that may run for hours before a crash, unbuffered output is the difference between seeing the error in real time and losing it entirely . The PYTHONDONTWRITEBYTECODE=1 variable prevents .pyc files from being written to the container’s writable layer, which saves space and avoids stale bytecode issues when code is mounted or rebuilt.

The --timeout flag deserves attention. Gunicorn kills a worker that does not respond within the timeout period. For APIs with slow database queries or external service calls, the default 30-second timeout may be too aggressive. FastAPI endpoints that perform inference or wait on slow upstreams should raise this to 120 seconds or more, matching the application’s actual latency profile .


b. The Django Dockerfile: Gunicorn with sync workers

Django’s default request model is synchronous WSGI. Unless the project is explicitly configured for ASGI with Django Channels or daphne, the appropriate Gunicorn worker class is the default sync worker. The Dockerfile structure is otherwise identical: build dependencies in a stage, copy to runtime, run as non-root.

# syntax=docker/dockerfile:1

FROM python:3.12-slim AS builder

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt

FROM python:3.12-slim

WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .

RUN useradd --create-home --shell /bin/bash appuser
USER appuser

ENV PATH=/root/.local/bin:$PATH
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

EXPOSE 8000

# Django WSGI application: Gunicorn default sync workers.
CMD ["gunicorn", "myproject.wsgi:application", \
     "--workers", "3", \
     "--bind", "0.0.0.0:8000", \
     "--timeout", "60", \
     "--access-logfile", "-", \
     "--error-logfile", "-"]

The worker count for Django follows the WSGI formula more closely because Django’s sync workers block on database queries and template rendering. (2 × CPU) + 1 is a reasonable starting point, adjusted downward if memory is constrained . For a container with 2 CPU cores and 512MB of memory, three workers may be too many if each Django process consumes 200MB. The memory limit, not the CPU count, decides the ceiling.

A Django-specific consideration: static files. Gunicorn does not serve static files. In production, a reverse proxy (Nginx, Traefik) or a dedicated static file service (WhiteNoise, S3) handles /static/ and /media/. The Dockerfile should not attempt to serve static files through Gunicorn; that is a deployment architecture decision outside the container’s scope.


c. Worker configuration, health checks, and container pitfalls

The Gunicorn command line is the starting point; a gunicorn.conf.py file provides finer control over timeouts, logging, and worker recycling.

# gunicorn.conf.py
import multiprocessing
import os

# Worker count: start with CPU count for async, (2*CPU)+1 for sync.
# Override via environment variable in deployment.
workers = int(os.getenv("GUNICORN_WORKERS", multiprocessing.cpu_count()))

# Async worker for FastAPI; omit for Django WSGI.
worker_class = "uvicorn.workers.UvicornWorker"

# Worker heartbeat temp directory. /dev/shm is RAM-backed and faster.
worker_tmp_dir = "/dev/shm"

# Bind to all interfaces inside the container.
bind = f"0.0.0.0:{os.getenv('PORT', '8000')}"

# Timeout: kill unresponsive workers. Raise for slow endpoints.
timeout = int(os.getenv("GUNICORN_TIMEOUT", "120"))
graceful_timeout = 30
keepalive = 5

# Log to stdout/stderr for container log collection.
accesslog = "-"
errorlog = "-"
loglevel = os.getenv("LOG_LEVEL", "info")

# Recycle workers after N requests to mitigate memory leaks.
max_requests = 1000
max_requests_jitter = 100

The max_requests setting addresses a class of bugs that Docker makes visible: memory leaks in long-running Python processes. A worker that leaks 1MB per request will eventually hit the container’s memory limit and be killed by the OOM killer. Recycling workers after a bounded number of requests caps the leak’s impact, at the cost of occasional cold starts . The jitter prevents all workers from restarting simultaneously and causing a thundering herd.

Health checks are essential for container orchestration. A liveness endpoint answers “is the process alive?” and a readiness endpoint answers “can it accept traffic?” The readiness check should verify dependencies (database, cache) while the liveness check should not—a database outage should remove the pod from the load balancer, but it should not cause Kubernetes to restart a healthy application process .

from fastapi import FastAPI, Response
import asyncpg

app = FastAPI()

@app.get("/health/live")
async def liveness():
    # Never check dependencies here.
    return {"status": "alive"}

@app.get("/health/ready")
async def readiness():
    # Check dependencies that are required for traffic.
    try:
        conn = await asyncpg.connect(DATABASE_URL)
        await conn.fetchval("SELECT 1")
        await conn.close()
        return {"status": "ready"}
    except Exception:
        return Response(status_code=503, content="not ready")

The container-specific OOM pitfall deserves explicit mention. Gunicorn’s documentation describes a scenario where workers are killed with no log output: the kernel’s OOM killer sends SIGKILL, which cannot be caught, so Gunicorn logs a worker “Booting” line without the preceding crash trace . The fix is to raise the container’s memory limit or reduce the worker count. The symptom—silent worker restarts—looks like a Gunicorn bug but is a resource configuration issue.


Complete Example Session

# ============================================
# PART 1: FASTAPI APPLICATION ENTRY POINT
# ============================================
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    price: float

@app.get("/")
async def root():
    return {"message": "containerized FastAPI"}

@app.post("/items")
async def create_item(item: Item):
    # Simulate async database work.
    return {"item": item, "status": "created"}

# ============================================
# PART 2: REQUIREMENTS FILE
# ============================================
# fastapi==0.115.12
# uvicorn[standard]==0.34.3
# gunicorn==23.0.0

# ============================================
# PART 3: THE DOCKERFILE (FASTAPI)
# ============================================
# FROM python:3.12-slim AS builder
# WORKDIR /app
# COPY requirements.txt .
# RUN pip install --no-cache-dir --user -r requirements.txt
#
# FROM python:3.12-slim
# WORKDIR /app
# COPY --from=builder /root/.local /root/.local
# COPY . .
# RUN useradd --create-home appuser
# USER appuser
# ENV PATH=/root/.local/bin:$PATH
# ENV PYTHONUNBUFFERED=1
# EXPOSE 8000
# CMD ["gunicorn", "main:app", "-w", "4", \
#      "-k", "uvicorn.workers.UvicornWorker", \
#      "-b", "0.0.0.0:8000", "--access-logfile", "-"]

# ============================================
# PART 4: GUNICORN CONFIGURATION FILE
# ============================================
# gunicorn.conf.py
# import multiprocessing, os
# workers = int(os.getenv("GUNICORN_WORKERS", multiprocessing.cpu_count()))
# worker_class = "uvicorn.workers.UvicornWorker"
# worker_tmp_dir = "/dev/shm"
# bind = "0.0.0.0:8000"
# timeout = 120
# max_requests = 1000
# max_requests_jitter = 100
# accesslog = "-"
# errorlog = "-"

# ============================================
# PART 5: DJANGO EQUIVALENT CMD
# ============================================
# CMD ["gunicorn", "myproject.wsgi:application", \
#      "--workers", "3", \
#      "--bind", "0.0.0.0:8000", \
#      "--timeout", "60"]

# ============================================
# PART 6: HEALTH CHECK ENDPOINTS
# ============================================
@app.get("/health/live")
async def live():
    return {"status": "alive"}

@app.get("/health/ready")
async def ready():
    # Add real dependency checks here.
    return {"status": "ready"}

# ============================================
# PART 7: DOCKER COMPOSE FOR LOCAL STACK
# ============================================
# services:
#   api:
#     build: .
#     ports: ["8000:8000"]
#     environment:
#       - DATABASE_URL=postgresql://user:pass@db:5432/app
#     depends_on:
#       db:
#         condition: service_healthy
#   db:
#     image: postgres:16
#     environment:
#       POSTGRES_USER: user
#       POSTGRES_PASSWORD: pass
#       POSTGRES_DB: app
#     healthcheck:
#       test: ["CMD-SHELL", "pg_isready -U user -d app"]
#       interval: 5s

# ============================================
# PART 8: BUILD AND RUN
# ============================================
# docker build -t myapi .
# docker run -d -p 8000:8000 --name api myapi
# docker logs -f api

# ============================================
# PART 9: WORKER COUNT VALIDATION
# ============================================
# Check actual memory per worker:
# docker stats
# If 4 workers exceed the memory limit, reduce to 2 or 3.
# For async I/O, fewer workers with more concurrency is often fine.

# ============================================
# PART 10: THE COMPLETE FLOW
# ============================================
# 1. App code: FastAPI or Django.
# 2. Multi-stage Dockerfile: builder installs, runtime runs.
# 3. Gunicorn + UvicornWorker: process manager + async engine.
# 4. Health checks: liveness (no deps) and readiness (deps).
# 5. Validate worker count against memory limit.
# 6. Log to stdout; never buffer.

Quick Reference

Gunicorn Worker Classes

FrameworkWorker ClassReason
FastAPI (ASGI)uvicorn.workers.UvicornWorkerAsync event loop per worker
Django (WSGI)default (sync)Synchronous request handling
Django + Channels (ASGI)uvicorn.workers.UvicornWorkerWebSocket/async support

Worker Count Guidance

WorkloadStarting PointConstraint
Async I/O (FastAPI)CPU countMemory per worker
Sync WSGI (Django)(2 × CPU) + 1Memory per worker
CPU-bound in asyncCPU count + thread poolAvoid blocking event loop

Essential Dockerfile Flags

InstructionPurpose
PYTHONUNBUFFERED=1Logs appear immediately
PYTHONDONTWRITEBYTECODE=1No .pyc files in container
--user installSimplifies multi-stage copy
USER appuserNon-root execution

Gunicorn Configuration Flags

FlagProduction ValuePurpose
--timeout120 (slow endpoints)Kill stuck workers
--max-requests1000Recycle for memory leaks
--max-requests-jitter100Avoid thundering herd
--worker-tmp-dir/dev/shmFaster heartbeat
--access-logfile -stdoutContainer log collection

Best Practices

✅ Do This:

# Use multi-stage build to exclude build tools.             // ✅
FROM python:3.12-slim AS builder
COPY --from=builder /root/.local /root/.local

# Set PYTHONUNBUFFERED for real-time logs.                  // ✅
ENV PYTHONUNBUFFERED=1

# Run as non-root user.                                     // ✅
RUN useradd --create-home appuser
USER appuser
# Use UvicornWorker for FastAPI.                            // ✅
--worker-class uvicorn.workers.UvicornWorker

# Set max_requests to recycle leaky workers.                // ✅
--max-requests 1000 --max-requests-jitter 100

❌ Don’t Do This:

# Run Gunicorn as root.                                     // ❌
# USER root (default); container escape is catastrophic.

# Copy .env files into the image.                           // ❌
COPY .env . # Secrets in image layers.
# Use sync workers for FastAPI.                             // ❌
gunicorn main:app -w 4  # Wrong worker class; async broken.

# Use (2*CPU)+1 blindly for async.                          // ❌
# Async I/O may need fewer workers, more memory headroom.

Common Pitfalls

PitfallWhy It HappensFix
Workers silently killedOOM killer sends SIGKILL, unloggedRaise memory limit or reduce workers
Logs missing until crashPython buffers stdoutPYTHONUNBUFFERED=1
FastAPI 502 with GunicornSync workers cannot handle ASGIUse UvicornWorker
Worker timeout on slow endpointDefault 30s too shortRaise --timeout
Memory grows over timePer-request leak in worker--max-requests recycling
Image bloatBuild tools in runtime stageMulti-stage build

Real-World Examples

1. FastAPI CMD with UvicornWorker

CMD ["gunicorn", "main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]

2. Django CMD with Sync Workers

CMD ["gunicorn", "myproject.wsgi:application", "--workers", "3", "--bind", "0.0.0.0:8000"]

3. Unbuffered Logging

ENV PYTHONUNBUFFERED=1

4. Non-Root User

RUN useradd --create-home appuser && USER appuser

5. Health Check Endpoint

@app.get("/health/live")
async def live(): return {"status": "alive"}

6. Worker Recycling

--max-requests 1000 --max-requests-jitter 100

7. RAM-Backed Heartbeat

--worker-tmp-dir /dev/shm

8. Compose with DB Health Gate

depends_on:
  db:
    condition: service_healthy

9. Multi-Stage Copy

COPY --from=builder /root/.local /root/.local

10. Check Memory per Worker

docker stats --no-stream

Visual

Gunicorn + UvicornWorker Architecture

┌──────────────────────────────────────────────┐
│  CONTAINER                                   │
│                                              │
│  ┌────────────────────────────────────┐      │
│  │  Gunicorn (Process Manager)        │      │
│  │  - Spawns workers                  │      │
│  │  - Restarts on crash               │      │
│  │  - Distributes connections         │      │
│  └────────────────────────────────────┘      │
│         │                                    │
│         ├──► Worker 0 (Uvicorn)              │
│         │      Event loop, async I/O         │
│         ├──► Worker 1 (Uvicorn)              │
│         │      Event loop, async I/O         │
│         └──► Worker N (Uvicorn)              │
│                Event loop, async I/O         │
│                                              │
│  Each worker: one process, one event loop.   │
│  Gunicorn manages lifecycle; Uvicorn handles │
│  concurrency within each worker.             │
│                                              │
└──────────────────────────────────────────────┘

Multi-Stage Build

┌──────────────────────────────────────────────┐
│  BUILDER STAGE          RUNTIME STAGE        │
│                                              │
│  python:3.12-slim       python:3.12-slim     │
│       │                      │               │
│  pip install --user          │               │
│       │                      │               │
│  /root/.local ──────────► /root/.local       │
│  (packages)              (copied)            │
│                              │               │
│  Build tools remain.     No build tools.     │
│  Large image.            Small image.        │
│                                              │
│  Final image contains only:                  │
│    - Python runtime                          │
│    - Installed packages                      │
│    - Application code                        │
│                                              │
└──────────────────────────────────────────────┘

Worker Count Decision

┌──────────────────────────────────────────────┐
│  HOW MANY WORKERS?                           │
│                                              │
│  Workload type?                              │
│       │                                      │
│       ├── Async I/O (FastAPI)                │
│       │     Start: CPU count                 │
│       │     Constraint: memory per worker    │
│       │                                      │
│       ├── Sync WSGI (Django)                 │
│       │     Start: (2 × CPU) + 1             │
│       │     Constraint: memory per worker    │
│       │                                      │
│       └── CPU-bound inside async             │
│             Push to thread pool              │
│             Worker count = CPU count         │
│                                              │
│  Always validate with: docker stats          │
│  If OOM kills: reduce workers or raise limit.│
│                                              │
└──────────────────────────────────────────────┘

Container Logging Flow

┌──────────────────────────────────────────────┐
│  STDOUT → DOCKER LOGS                        │
│                                              │
│  Gunicorn --access-logfile -                 │
│  Gunicorn --error-logfile -                  │
│  PYTHONUNBUFFERED=1                          │
│       │                                      │
│       ▼                                      │
│  Container stdout/stderr                     │
│       │                                      │
│       ▼                                      │
│  docker logs / orchestrator collector        │
│                                              │
│  Without PYTHONUNBUFFERED:                   │
│    Logs buffered in memory.                  │
│    Crash loses buffered logs.                │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
FastAPI process managerGunicorn
FastAPI worker classuvicorn.workers.UvicornWorker
Django worker classDefault sync (unless ASGI)
Worker count (async)Start at CPU count, constrained by memory
Worker count (sync)(2 × CPU) + 1, constrained by memory
Multi-stage buildBuilder installs, runtime copies
PYTHONUNBUFFEREDRequired for real-time logs
max_requestsRecycle workers to cap leaks
worker_tmp_dir/dev/shm for faster heartbeats
OOM killer symptomSilent worker restarts, no error log

Key takeaways:

  • Gunicorn manages processes; UvicornWorker provides async I/O. The combination gives FastAPI multi-process parallelism with event-loop concurrency inside each worker. Django uses Gunicorn’s default sync workers unless configured for ASGI .
  • The worker count formula is a starting point, not a rule. Async FastAPI workloads often perform well with fewer workers than (2 × CPU) + 1, because each worker handles many concurrent connections. Memory per worker determines the ceiling.
  • Multi-stage builds keep the runtime image minimal. Build tools installed in the builder stage are excluded from the final image, reducing size and attack surface .
  • PYTHONUNBUFFERED=1 is mandatory for container logs. Without it, stdout is buffered and crash logs are lost when the process dies abruptly.
  • OOM kills are silent. When a worker exceeds the container’s memory limit, the kernel sends SIGKILL. Gunicorn cannot log the signal, and the worker appears to restart without cause . Reduce workers or raise the limit.
  • Health checks separate liveness from readiness. Liveness answers “is the process alive?” without checking dependencies. Readiness checks database and cache connectivity and returns 503 when dependencies are unavailable .
  • max_requests recycling mitigates memory leaks. Python workers that grow over time are recycled after a bounded number of requests, with jitter to prevent simultaneous restarts.

Remember: Containerizing a Python web application is not just wrapping uvicorn in a Dockerfile. The production pattern uses Gunicorn as a process manager and UvicornWorker as the async engine, with a worker count validated against memory limits rather than CPU count alone. The Dockerfile is multi-stage, the runtime user is non-root, and the logs are unbuffered. Health checks distinguish liveness from readiness, and max_requests recycling caps the impact of leaks. For Django, the same pattern applies with sync workers unless ASGI is explicitly configured. The container provides isolation and reproducibility; the process model provides concurrency and resilience.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!