← Back to list

Dockerizing Your Python API — From “It Works on My Machine” to Production

If it doesn’t run in a container, it doesn’t run.

Anas Issath in Level Up Coding · 2026-04-29 14:50 · 188 claps · 9.9 min read paywalled
#django #fastapi #docker #docker-containerization #backend-development
Open on Medium ↗
Wiki topics: 🌐 · Web Development ☁️ · DevOps & Cloud

Dockerizing Your Python API — From “It Works on My Machine” to Production

If it doesn’t run in a container, it doesn’t run.

Same Container Everywhere

Same Container Everywhere

“It works on my machine.”

Those five words have caused more production incidents than any bug I’ve ever written. A teammate pushed code that worked perfectly on their Mac with Python 3.11. Our staging server ran Python 3.10. A minor syntax change in a type hint — valid in 3.11, syntax error in 3.10 — took down the deployment pipeline for two hours on a Friday afternoon.

We containerized the project that weekend. Docker doesn’t care what’s on your laptop, your staging server, or your production host. It runs the same image everywhere. Same Python version. Same system libraries. Same dependencies. Same behavior.

But most Docker tutorials for Python projects get you 80% there and leave out the 20% that matters for production — multi-stage builds that cut image size in half, non-root users that prevent container escapes, health checks that tell your orchestrator when the app is actually ready, and the layer caching mistakes that make your builds ten times slower than necessary.

Here’s the Docker setup I use for every Django and FastAPI project. Copy-paste ready, production-tested, and explained line by line.

The Naive Dockerfile (And Why It’s a Problem)

Why the Naive Dockerfile Fails Production

Why the Naive Dockerfile Fails Production

This is what most tutorials show you:

FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

It works. It also has six problems:

Full Python image is 1.2GB. python:3.12 includes compilers, development headers, and tools you'll never use in production. Your deploy takes minutes instead of seconds.

Runs as root. If an attacker exploits a vulnerability in your app, they have root access to the container — and potentially the host.

No layer caching. COPY . . copies everything before installing dependencies. Change one line of code and Docker reinstalls all your packages.

Uses the dev server. manage.py runserver is single-threaded, unoptimized, and Django's own docs explicitly say never to use it in production.

No health check. Your orchestrator (Kubernetes, ECS, Docker Compose) can’t tell if the app is actually healthy. A crashed process that holds the port open looks “up” from the outside.

Secrets in the image. If .env is in the build context, it gets baked into the image. Anyone with access to the image can extract your secrets.

Let’s fix all six.

The Production Dockerfile: Django

Multi-Stage Python Docker Build

Multi-Stage Python Docker Build

# ============================================
# Stage 1: Build dependencies
# ============================================
FROM python:3.12-slim AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

WORKDIR /app

# Install build dependencies (needed for some Python packages)
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    gcc \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Install Python dependencies in a virtual env
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install -r requirements.txt

# ============================================
# Stage 2: Production image
# ============================================
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

# Install only runtime dependencies (no compiler)
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    libpq5 \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser -s /bin/false appuser

WORKDIR /app

# Copy virtual env from builder stage
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# Copy application code
COPY . .

# Collect static files (Django-specific)
RUN python manage.py collectstatic --noinput 2>/dev/null || true

# Set ownership and switch to non-root user
RUN chown -R appuser:appuser /app
USER appuser

EXPOSE 8000

# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8000/api/health/ || exit 1

# Production server: Gunicorn with 4 workers
CMD ["gunicorn", "myproject.wsgi:application", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "4", \
     "--worker-tmp-dir", "/dev/shm", \
     "--timeout", "120", \
     "--access-logfile", "-", \
     "--error-logfile", "-"]

Let me explain every decision.

Multi-Stage Build

The first stage (builder) installs gcc and libpq-dev — build tools needed to compile Python packages like psycopg2. The second stage only includes libpq5 — the runtime library. The compiler never makes it to the production image.

Result: image size drops from 1.2GB to about 250MB. Deploys are faster. Attack surface is smaller.

Virtual Environment in Docker

Most tutorials skip the virtual env inside Docker (“the container IS the environment”). But using a venv inside a multi-stage build gives you a clean way to copy only the installed packages between stages. COPY --from=builder /opt/venv /opt/venv grabs the entire dependency tree without the build tools.

Non-Root User

USER appuser means if someone exploits a vulnerability in your Django app, they're a non-privileged user — not root. They can't install packages, modify system files, or escape the container.

Layer Caching for Dependencies

Docker Layer Caching for Python Dependencies

Docker Layer Caching for Python Dependencies

Notice that requirements.txt is copied before the rest of the code:

COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

Docker caches each layer. If requirements.txt hasn't changed, Docker skips the pip install entirely. Only the COPY . . layer (your code) rebuilds. This cuts build time from 2-3 minutes to 5-10 seconds on most changes.

Gunicorn Over runserver

Gunicorn is a production WSGI server that runs multiple worker processes. Four workers can handle four concurrent requests. --worker-tmp-dir /dev/shm uses shared memory for the heartbeat file, which is faster and avoids issues on Docker's overlay filesystem.

The Production Dockerfile: FastAPI

# ============================================
# Stage 1: Build dependencies
# ============================================
FROM python:3.12-slim AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

WORKDIR /app

RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc libpq-dev && \
    rm -rf /var/lib/apt/lists/*

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install -r requirements.txt

# ============================================
# Stage 2: Production image
# ============================================
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

RUN apt-get update && \
    apt-get install -y --no-install-recommends libpq5 curl && \
    rm -rf /var/lib/apt/lists/*

RUN groupadd -r appuser && useradd -r -g appuser -s /bin/false appuser

WORKDIR /app

COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY . .

RUN chown -R appuser:appuser /app
USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8000/api/health || exit 1

# Uvicorn with 4 workers for production
CMD ["uvicorn", "app.main:app", \
     "--host", "0.0.0.0", \
     "--port", "8000", \
     "--workers", "4", \
     "--no-access-log"]

The structure is almost identical to Django. The only differences: no collectstatic, and uvicorn instead of gunicorn. For higher performance under heavy load, you can use Gunicorn as a process manager with Uvicorn workers:

CMD ["gunicorn", "app.main:app", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "4", \
     "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--timeout", "120"]

This gives you Gunicorn’s process management (auto-restarting crashed workers) with Uvicorn’s ASGI performance.

Docker Compose for Local Development

Docker Compose Dev and Production Split

Docker Compose Dev and Production Split

Production Dockerfiles and development workflows have different needs. Development wants hot-reloading, debug mode, and access to the database. Production wants security, performance, and minimal images.

# docker-compose.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    volumes:
      - .:/app  # Hot-reload: code changes reflect immediately
    environment:
      - DEBUG=true
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/myapp
      - REDIS_URL=redis://redis:6379/0
      - SECRET_KEY=dev-secret-not-for-production
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    # Override CMD for development (with auto-reload)
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=myapp
    ports:
      - "5432:5432"
    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
    ports:
      - "6379:6379"

volumes:
  postgres_data:

Key details:

Volume mount (- .:/app) overrides the copied code with your local filesystem. You edit files on your machine, and the container sees the changes instantly. Combined with --reload, every save triggers a server restart.

**depends_on with health check** ensures the database is actually accepting connections before the API starts — not just that the container is running. Without the health check condition, your API might start before PostgreSQL finishes initializing and crash on the first database query.

The command override replaces the production CMD with a development-friendly command that includes --reload. The Dockerfile still has the production command — Compose just overrides it locally.

The .dockerignore File (Don’t Skip This)

Clean Docker Build Context

Clean Docker Build Context

Without .dockerignore, Docker copies everything into the build context — including your .git directory, node_modules, virtual environments, and .env files:

# .dockerignore
.git
.gitignore
.env
.env.*
*.md
LICENSE

# Python
__pycache__
*.pyc
*.pyo
.venv
venv
.mypy_cache
.pytest_cache

# IDE
.vscode
.idea

# Testing
tests/
htmlcov/
.coverage

# Docker
docker-compose*.yml
Dockerfile*

The .git directory alone can be hundreds of megabytes. Excluding it cuts build context transfer from 30 seconds to under 1 second. More importantly, excluding .env prevents secrets from being baked into the image.

The Mistakes That Break Deployments

Docker Deployment Anti-Patterns

Docker Deployment Anti-Patterns

Mistake 1: Running Migrations in the Dockerfile

# BAD — migration runs at build time, not deploy time
RUN python manage.py migrate

Migrations should run at deploy time, not build time. The build might happen on a CI server that can’t reach your production database. And you want the same image to work in staging and production — both have different databases.

Run migrations as a separate step in your deployment pipeline:

# docker-compose.yml — deploy section
services:
  migrate:
    image: myapp:latest
    command: python manage.py migrate --noinput
    depends_on:
      db:
        condition: service_healthy
    environment:
      - DATABASE_URL=${DATABASE_URL}

Or in Kubernetes, use an init container:

initContainers:
  - name: migrate
    image: myapp:latest
    command: ["python", "manage.py", "migrate", "--noinput"]
    envFrom:
      - secretRef:
          name: app-secrets

Mistake 2: Using latest Tag in Production

# BAD — what does "latest" mean? Which version? When was it built?
image: myapp:latest
# GOOD — specific version tied to a commit or release
image: myapp:v1.2.3
# or
image: myapp:abc1234  # git commit hash

latest is mutable. If someone pushes a new image, every deployment that uses latest gets the new version — whether you wanted it or not. Pin your versions.

Mistake 3: Not Setting Resource Limits

Without limits, a memory leak in your Python app can consume all available memory on the host and kill other containers:

services:
  api:
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"
        reservations:
          memory: 256M
          cpus: "0.5"

Start with 512MB for a typical Django/FastAPI app with 4 workers. Monitor actual usage and adjust. If your app consistently uses 400MB, the limit is too tight. If it uses 150MB, the limit is too generous.

Mistake 4: Ignoring the Build Cache

Every instruction in a Dockerfile creates a layer. Docker caches layers and reuses them if the input hasn’t changed. The order of your instructions determines how effectively this cache works:

# BAD order — changing any code invalidates the pip install cache
COPY . .
RUN pip install -r requirements.txt

# GOOD order — pip install is cached unless requirements.txt changes
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

In CI/CD pipelines, use --cache-from to leverage cached layers from previous builds:

docker build \
  --cache-from myapp:latest \
  --tag myapp:${GIT_SHA} \
  .

The Health Check Endpoint

The HEALTHCHECK in your Dockerfile needs an endpoint to hit. Here's what I use:

# FastAPI
@app.get("/api/health")
def health_check():
    return {"status": "ok", "version": settings.VERSION}

# Django
from django.http import JsonResponse
def health_check(request):
    return JsonResponse({"status": "ok", "version": settings.VERSION})

For a deeper health check that verifies database connectivity:

@app.get("/api/health")
def health_check(db: Session = Depends(get_db)):
    try:
        db.execute(text("SELECT 1"))
        db_status = "connected"
    except Exception:
        db_status = "disconnected"
        return JSONResponse(
            status_code=503,
            content={"status": "unhealthy", "database": db_status}
        )

    return {"status": "ok", "database": db_status}

Return 200 when healthy, 503 when not. Your orchestrator uses this to decide whether to route traffic to this instance or replace it.

The Complete Production Setup

Complete Python API Docker Setup

Complete Python API Docker Setup

Here’s what I have at the end of every project:

project/
├── Dockerfile              # Multi-stage, production-ready
├── docker-compose.yml      # Local development with hot-reload
├── .dockerignore           # Exclude .git, .env, tests, etc.
├── requirements.txt        # Pinned dependencies
├── app/
│   ├── main.py
│   └── ...
└── deploy/
    ├── nginx.conf          # Reverse proxy config
    └── docker-compose.prod.yml  # Production overrides

The deploy/docker-compose.prod.yml overrides development settings:

# deploy/docker-compose.prod.yml
services:
  api:
    restart: always
    volumes: []  # No volume mount in production
    command: []  # Use Dockerfile CMD (production server)
    environment:
      - DEBUG=false
    deploy:
      resources:
        limits:
          memory: 512M

Run in production:

docker compose -f docker-compose.yml -f deploy/docker-compose.prod.yml up -d

The second file overrides the first. Development gets hot-reload and debug mode. Production gets the production server, no volume mounts, and resource limits. Same base config, different overrides.

Bottom Line

Docker removes an entire category of bugs — the “it works on my machine” category. Same Python version, same system libraries, same dependency versions, same behavior. Every time. Everywhere.

The production Dockerfile I’ve shared takes about 30 minutes to set up: multi-stage build, non-root user, proper layer caching, health checks, and a production-grade server. It’s the same structure I’ve used on five projects, and the only thing that changes between them is the CMD line and the requirements.txt.

That Friday afternoon incident with the Python 3.10 vs 3.11 mismatch? Never happened again. Every developer runs the same container. Every CI pipeline builds the same image. Every server deploys the same artifact. Docker doesn’t make your code better, but it makes your deployments boring. And boring deployments are the best kind.

What does your Docker setup look like? I’m curious how other teams handle the dev vs production split — separate Dockerfiles, multi-stage with overrides, or something else entirely? And has anyone switched from Docker Compose to Kubernetes? I’d love to hear when it was worth it. Share in the comments.

Thanks for reading! ❤

If this helped you, consider clapping (50 👏 s), following, or sharing it. A writer without readers is just talking to themselves — so your time means everything.

Let’s keep building better, together.

Anas Issath


메타데이터
post_id
339758a865e8
slug
dockerizing-your-python-api-from-it-works-on-my-machine-to-production-339758a865e8
url
https://levelup.gitconnected.com/dockerizing-your-python-api-from-it-works-on-my-machine-to-production-339758a865e8
canonical_url
https://levelup.gitconnected.com/dockerizing-your-python-api-from-it-works-on-my-machine-to-production-339758a865e8
author_url
https://medium.com/@anas-issath
status
ok
fetched_at
2026-09-10 16:51:59