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.
Table of Contents
- Choosing the Right Base Image
- .dockerignore — What to Always Exclude
- Layer Caching and Dependency Installation
- Multi-Stage Builds
- Running as a Non-Root User
- Production Dockerfile: FastAPI
- Production Dockerfile: Django
- Production Dockerfile: Flask
- docker-compose for Local Development
- Health Checks and Environment Variables
- Frequently Asked Questions
Choosing the Right Base Image
The official Python images on Docker Hub come in several flavours. Here is how to pick:
| Image Tag | Size (approx) | Use Case |
|---|---|---|
python:3.12 | ~1 GB | Development / debugging only |
python:3.12-slim | ~130 MB | Default production choice |
python:3.12-alpine | ~55 MB | Smallest, but musl libc causes C-extension pain |
gcr.io/distroless/python3 | ~50 MB | Hardened, no shell — best for security-critical workloads |
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 $(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", "-"]
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.