How to Write a Dockerfile for a Python App (Optimized for Production)
Most Dockerfiles you find online work fine on your laptop โ and quietly cause headaches in production. This guide shows you how to build Python container images that are lean, secure, and genuinely ready to ship.
Why your Dockerfile matters more than you think
Docker has made shipping software dramatically simpler โ but there's a gap between a Dockerfile that works and one that's fit for production. A naive setup can give you images over 1 GB in size, containers running as root, secrets baked into layers, and build times so slow your CI pipeline becomes the team's biggest complaint.
The good news: fixing all of that takes the same Dockerfile. You just need to understand a handful of principles that compound well together. By the end of this guide you'll have a template you can drop into any Python project โ FastAPI, Flask, Django, a bare script โ and confidently push to production.
Prerequisites
You should be comfortable with the basics of Docker (docker build, docker run) and have a Python project with a requirements.txt or pyproject.toml. Everything else is covered here.
.webp)
Choose the right base image
Your base image is the foundation everything else sits on. Getting it wrong means dragging hundreds of megabytes of unnecessary tooling into every deployment.
The official Python images on Docker Hub come in a few flavors. Here's a quick breakdown:
| Tag | Approx. size | Best for |
|---|---|---|
| python:3.12 | ~900 MB | Never use in production โ includes build tools, docs, and a lot you don't need |
| python:3.12-slim | ~130 MB | The sweet spot for most apps โ Debian-based, minimal, widely compatible |
| python:3.12-slim-bookworm | ~130 MB | Same as slim but pinned to Debian Bookworm โ preferred for reproducibility |
| python:3.12-alpine | ~50 MB | Smallest, but uses musl libc which can break packages that expect glibc |
For the vast majority of Python projects, python:3.12-slim-bookworm is the right call. Alpine sounds attractive but regularly causes compilation failures with popular libraries like numpy, pydantic, and cryptography โ the debugging time rarely justifies the size savings.
Always pin a specific version rather than using python:latest. Floating tags are a silent breaking-change waiting to happen.
# Pin the version. Reproducibility is a feature.
FROM python:3.12-slim-bookwormLayer caching โ the #1 speed lever
Every instruction in a Dockerfile produces a layer. Docker caches each layer and reuses it on rebuild โ as long as neither that instruction nor anything above it has changed.
The most common mistake is copying all your source code before installing dependencies. Your source code changes constantly. Your requirements.txt changes rarely. If you mix them up, pip reinstalls everything on every build.
โ Cache-busting (slow)
COPY . /app
WORKDIR /app
# pip reinstalls EVERY time
# any file changes โ even a
# comment in main.py
RUN pip install -r requirements.txtโ Cache-friendly (fast)
WORKDIR /app
# Copy deps manifest first
COPY requirements.txt .
# This layer is cached until
# requirements.txt changes
RUN pip install -r requirements.txt
# Source code comes last
COPY . .The rule of thumb: order your instructions from least frequently changed to most frequently changed. System packages go first, then dependency manifests, then your application code.
Speed up pip with a build cache mount
If you're on BuildKit (the default since Docker 23), you can mount pip's cache between builds so it doesn't re-download wheels it's already fetched:
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txtNote the --no-cache-dir flag tells pip not to store its own cache inside the image layer โ the BuildKit mount handles caching externally.
Multi-stage builds for smaller images
Some Python packages โ anything involving C extensions, for example โ require build tools (gcc, g++, libpq-dev) at install time but not at runtime. Multi-stage builds let you use a fat builder image to compile everything, then copy only the artifacts into a lean final image.
The result is an image that contains your app and its compiled dependencies โ nothing else.
# syntax=docker/dockerfile:1
# โโ Stage 1: builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
FROM python:3.12-slim-bookworm AS builder
# Install any system build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy and install Python dependencies into /install
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir --prefix=/install -r requirements.txt
# โโ Stage 2: runtime โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
FROM python:3.12-slim-bookworm AS runtime
# Only runtime system deps (e.g., libpq for psycopg2)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Pull the installed packages from builder โ no gcc, no headers
COPY --from=builder /install /usr/local
# Copy application source
COPY . .This pattern routinely cuts image sizes in half. A FastAPI app that clocked in at 650 MB with a single-stage build can drop to under 200 MB with this approach โ faster to push, faster to pull, smaller attack surface.
Security: running as a non-root user
By default, Docker containers run as root inside the container. That's a problem: if an attacker exploits your application and escapes the container, they have root on the host (with some caveats around namespaces, but don't rely on those).
The fix is one short block at the end of your Dockerfile:
# Create a system user with no home dir, no shell
RUN groupadd --system appgroup && \
useradd --system --gid appgroup --no-create-home appuser
# Ensure the app directory is owned by this user
RUN chown -R appuser:appgroup /app
# Switch to non-root for everything that follows
USER appuserDo this after you've copied files and set permissions โ otherwise the user won't have read access to what it needs.
Other security quick-wins
- โPin system package versions: Use apt-get install package=1.2.3 rather than the latest, so your image doesn't silently change between builds.
- โAdd a .dockerignore file: Exclude .git, __pycache__, .env files, test directories, and local venvs from the build context. This also speeds up builds significantly.
- โScan your image: Run docker scout cves or trivy image my-app:latest in your CI pipeline to catch known CVEs before they reach production.
- โDon't install unnecessary packages: Every extra package is a potential vulnerability. apt-get install --no-install-recommends is your friend.
Environment variables and secrets
Here's one of the most common mistakes in containerized Python apps: a developer needs a database password during the build, so they write:
# โ NEVER do this โ this secret is now baked into the image layer
ARG DB_PASSWORD=supersecret
ENV DB_PASSWORD=$DB_PASSWORDEven if you delete it in a later layer, the secret is still visible in the image's history (docker history my-image). Anyone with access to the image can read it.
The right pattern depends on when you need the value:
# Secret is injected at build time but never stored in the layer
RUN --mount=type=secret,id=pip_token \
PIP_INDEX_URL=$(cat /run/secrets/pip_token) \
pip install --no-cache-dir -r requirements.txt
# Build with:
# docker build --secret id=pip_token,env=PIP_TOKEN .# Don't set them in the Dockerfile at all.
# Pass them at runtime:
docker run -e DATABASE_URL="postgres://..." my-app
# Or use a secrets manager (AWS Secrets Manager, Vault, etc.)
# and inject at startup via your entrypoint script.Environment variables set with ENV in your Dockerfile are fine for non-sensitive configuration โ Python version pins, locale settings, disabling pip's version check, and so on.
# Sensible Python env defaults โ these are safe to bake in
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1Why PYTHONUNBUFFERED=1?
Without it, Python buffers stdout and stderr. That means crash logs can disappear if your container dies before the buffer flushes. Setting this ensures output goes straight to your logging system.
The complete production Dockerfile
Here's everything pulled together โ a Dockerfile that's well-cached, minimal, non-root, and ready for a real workload. This example targets a FastAPI app served with Uvicorn, but the structure applies to any Python web app.
# syntax=docker/dockerfile:1
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Production Dockerfile โ Python / FastAPI
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# โโ Stage 1: build dependencies โโโโโโโโโโโโโโโโโโโโโโโโโ
FROM python:3.12-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir --prefix=/install -r requirements.txt
# โโ Stage 2: production runtime โโโโโโโโโโโโโโโโโโโโโโโโโ
FROM python:3.12-slim-bookworm AS runtime
# Python env โ safe, non-secret config
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Runtime system deps only (no build tools)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user
RUN groupadd --system appgroup && \
useradd --system --gid appgroup --no-create-home appuser
WORKDIR /app
# Copy compiled packages from builder
COPY --from=builder /install /usr/local
# Copy application source
COPY --chown=appuser:appgroup . .
# Switch to non-root
USER appuser
# Expose the port your app runs on
EXPOSE 8000
# Healthcheck โ lets orchestrators know the app is ready
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Start the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]Pair it with a solid .dockerignore
A lean build context is as important as a lean image. Drop this in your project root:
.git
.gitignore
.env
.env.*
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.mypy_cache
.ruff_cache
venv/
.venv/
dist/
*.egg-info
docs/
tests/
*.md
Dockerfile*
docker-compose*Building and running your image
With your Dockerfile in place, building is straightforward:
# Build and tag
docker build -t my-python-app:1.0.0 .
# Check the final image size
docker images my-python-app
# Run locally, passing env vars at runtime (not baked in)
docker run -p 8000:8000 \
-e DATABASE_URL="postgres://user:pass@db/mydb" \
-e SECRET_KEY="your-secret-key" \
my-python-app:1.0.0Composing with a database locally
For local development with a database, a minimal docker-compose.yml keeps things tidy:
services:
app:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgres://appuser:apppass@db:5432/appdb
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: apppass
POSTGRES_DB: appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser"]
interval: 5s
timeout: 3s
retries: 5Production readiness checklist
Before pushing your image to a registry, run through this list:
Wrapping up
A production-ready Dockerfile isn't complicated โ it's just a set of deliberate decisions applied consistently. Pin your base image. Order your layers for cache efficiency. Use multi-stage builds to keep runtimes lean. Never bake in secrets. Run as a non-root user. Add a healthcheck.
Do all of that and you'll have images that are faster to build, cheaper to store, safer to run, and easier to debug when something goes wrong. That's not premature optimization โ it's just good engineering.
If you want to go deeper, the natural next steps are image scanning in CI with Trivy or Docker Scout, setting up a private container registry (ECR, GCR, or GitHub Packages), and thinking about reproducible builds by locking dependencies with pip-compile or uv lock.