Dockerizing Python Apps: FastAPI, Django and Flask (2026)

Containerising a Python web application sounds straightforward until you hit a 1.4 GB image in production, a container running as root, or a Django collectstatic that fails at startup. This guide walks through every decision that matters — base image selection, layer caching, multi-stage builds, secrets handling, health checks, and docker-compose for local development — with complete, copy-paste Dockerfiles for FastAPI, Django, and Flask.

Choosing the Right Base Image

The official Python images on Docker Hub come in several flavours. Here is how to pick:

Image TagSize (approx)Use Case
python:3.12~1 GBDevelopment / debugging only
python:3.12-slim~130 MBDefault production choice
python:3.12-alpine~55 MBSmallest, but musl libc causes C-extension pain
gcr.io/distroless/python3~50 MBHardened, no shell — best for security-critical workloads
Alpine Gotcha: Many Python packages (numpy, Pillow, psycopg2) compile C extensions that assume glibc. On Alpine (musl libc) you may need to compile from source, dramatically increasing build time and image size. Prefer slim unless image size is a hard constraint.

.dockerignore — What to Always Exclude

# .dockerignore
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.venv/
venv/
env/
.env
.env.*
*.sqlite3
.git/
.gitignore
.dockerignore
tests/
docs/
*.md
.pytest_cache/
.mypy_cache/
.ruff_cache/
htmlcov/
.coverage
node_modules/

A proper .dockerignore prevents your virtualenv (potentially hundreds of MB) and git history from landing inside the image context, which speeds up every docker build.

Layer Caching and Dependency Installation

Docker caches each layer. The key rule: copy dependency manifests first, install, then copy source. This way, a code change does not invalidate the package-installation layer.

# WRONG — code change invalidates pip install layer
COPY . /app
RUN pip install -r requirements.txt

# CORRECT — pip layer only rebuilds when requirements change
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY . /app

Use pip install --no-cache-dir to avoid storing the pip wheel cache inside the image layer.

Multi-Stage Builds

Multi-stage builds compile or install in a heavyweight "builder" stage, then copy only the artefacts into a lean runtime image.

# ---- Builder stage ----
FROM python:3.12-slim AS builder

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

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# ---- Runtime stage ----
FROM python:3.12-slim AS runtime

WORKDIR /app

# Copy installed packages from builder
COPY --from=builder /install /usr/local

# Copy application source
COPY src/ ./src/

EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

This approach keeps build tools (gcc, libpq-dev) out of the final image.

Running as a Non-Root User

Containers run as root by default. If exploited, an attacker has root access on the host (especially without user-namespace remapping). Always create a dedicated user:

FROM python:3.12-slim

RUN groupadd --gid 1001 appgroup \
    && useradd --uid 1001 --gid appgroup --shell /bin/sh --create-home appuser

WORKDIR /app
COPY --chown=appuser:appgroup requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY --chown=appuser:appgroup . .

USER appuser   # switch before CMD
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Production Dockerfile: FastAPI

FROM python:3.12-slim AS builder
WORKDIR /build

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

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# ---- Runtime ----
FROM python:3.12-slim
LABEL maintainer="team@techoral.com"

RUN groupadd -g 1001 app && useradd -u 1001 -g app -s /bin/sh -m app

WORKDIR /app
COPY --from=builder /install /usr/local
COPY --chown=app:app ./app ./app

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PORT=8000

USER app
EXPOSE ${PORT}

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
Workers: For CPU-bound workloads use --workers $(nproc). For async I/O-heavy FastAPI apps, 1–2 workers with high concurrency is often better than many workers.

Production Dockerfile: Django

FROM python:3.12-slim AS builder
WORKDIR /build

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

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# ---- Runtime ----
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
    && rm -rf /var/lib/apt/lists/*

RUN groupadd -g 1001 django && useradd -u 1001 -g django -s /bin/sh -m django

WORKDIR /app
COPY --from=builder /install /usr/local
COPY --chown=django:django . .

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    DJANGO_SETTINGS_MODULE=myproject.settings.production

USER django

# Run collectstatic at build time (pass SECRET_KEY as build arg)
ARG DJANGO_SECRET_KEY=placeholder
RUN SECRET_KEY=${DJANGO_SECRET_KEY} python manage.py collectstatic --noinput

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s CMD \
    python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/')"

CMD ["gunicorn", "myproject.wsgi:application", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "4", \
     "--timeout", "60", \
     "--access-logfile", "-"]
Static Files in Production: Never serve Django static files from Gunicorn in production. Mount a shared volume and serve via Nginx, or push to an S3/CloudFront distribution during your CI pipeline.

Production Dockerfile: Flask

FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.12-slim
RUN groupadd -g 1001 flask && useradd -u 1001 -g flask -s /bin/sh -m flask

WORKDIR /app
COPY --from=builder /install /usr/local
COPY --chown=flask:flask . .

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    FLASK_ENV=production

USER flask
EXPOSE 5000

HEALTHCHECK --interval=30s --timeout=5s CMD \
    python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/ping')"

CMD ["gunicorn", "wsgi:app", "--bind", "0.0.0.0:5000", "--workers", "4"]

docker-compose for Local Development

version: "3.9"

services:
  app:
    build:
      context: .
      target: builder   # use the builder stage for dev (has build tools)
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
    volumes:
      - ./app:/app/app   # live code reload
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://postgres:secret@db:5432/mydb
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: mydb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru

volumes:
  pgdata:

Health Checks and Environment Variables

Always add a HEALTHCHECK instruction so orchestrators (Docker Swarm, Kubernetes) know when a container is truly ready. For environment variables, never bake secrets into the image — pass them at runtime:

# Kubernetes / Docker run — inject at runtime
docker run \
  -e DATABASE_URL="postgresql://..." \
  -e SECRET_KEY="$(cat /run/secrets/django_key)" \
  myapp:latest

# For Kubernetes, use Secrets mounted as env vars:
# envFrom:
#   - secretRef:
#       name: myapp-secrets

Frequently Asked Questions

Should I use venv inside Docker?

It depends. A virtualenv inside Docker adds isolation but is redundant since the container is already isolated. The common pattern is a system-level pip install (pip install --prefix=/install) in the builder stage. Some teams prefer venv inside containers for parity with their local setup — both approaches work.

How do I reduce my Python Docker image from 1 GB to under 200 MB?

Switch from python:3.12 to python:3.12-slim, use a multi-stage build to exclude build tools, add a thorough .dockerignore, and run pip install --no-cache-dir. Combining these four steps typically brings images under 150 MB.

How do I handle database migrations in Docker?

Run migrations as a separate init container or an entrypoint script before starting the application server. Never run migrations inside the same process as your web workers — if you scale to multiple replicas all would try to migrate simultaneously.

Can I use Poetry instead of requirements.txt?

FROM python:3.12-slim AS builder
RUN pip install poetry==1.8.2
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt --output requirements.txt --without-hashes

FROM python:3.12-slim
COPY --from=builder requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

What is the difference between CMD and ENTRYPOINT in Python containers?

Use ENTRYPOINT for the fixed executable (e.g. ["gunicorn"]) and CMD for default arguments that can be overridden at docker run time. For simple cases, a single CMD is sufficient.