Development

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.

How to Write a Dockerfile for a Python App (Optimized for Production)

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:

TagApprox. sizeBest for
python:3.12~900 MBNever use in production โ€” includes build tools, docs, and a lot you don't need
python:3.12-slim~130 MBThe sweet spot for most apps โ€” Debian-based, minimal, widely compatible
python:3.12-slim-bookworm~130 MBSame as slim but pinned to Debian Bookworm โ€” preferred for reproducibility
python:3.12-alpine~50 MBSmallest, 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-bookworm

Layer 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.txt

Note 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 appuser

Do 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_PASSWORD

Even 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:

For build-time secrets (e.g. private PyPI tokens)
# 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 .
For runtime secrets (DB passwords, API keys)
# 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=1

Why 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.0

Composing 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: 5

Production 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.

Feedback

Live