Intermediate

You have built a Python application that works perfectly on your machine. Then you deploy it to a server, and everything breaks — different Python version, missing system libraries, conflicting dependencies. This scenario plays out daily across development teams worldwide, and Docker solves it completely. By packaging your application with its exact runtime environment, Docker guarantees that what works on your laptop works identically in production.

Docker is free, runs on all major operating systems, and requires no special Python knowledge beyond what you already have. You will need Docker Desktop installed on your machine (available from docs.docker.com), and a basic Python application to containerize. If you do not have one, we will create a simple Flask app from scratch in this tutorial.

In this article, you will learn how to write a Dockerfile for Python applications, build and run Docker images, use multi-stage builds to keep images small, manage dependencies properly, set up Docker Compose for multi-container apps, and follow production-ready best practices. By the end, you will be able to containerize any Python project with confidence.

Dockerizing a Python App: Quick Example

Before we dive deep, here is the fastest way to containerize a Python script. Create these two files in an empty directory:

# app.py
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello():
    return {"message": "Hello from Docker!", "status": "running"}

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
# requirements.txt
flask==3.0.0

Now build and run it with two commands:

# terminal commands
docker build -t my-python-app .
docker run -p 5000:5000 my-python-app

Output:

 * Serving Flask app 'app'
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
 * Running on http://172.17.0.2:5000

Visit http://localhost:5000 in your browser and you will see {"message": "Hello from Docker!", "status": "running"}. That is your Python app running inside a container — isolated, reproducible, and ready to deploy anywhere Docker runs. The rest of this article explains every piece in detail and shows you how to handle real-world scenarios.

What Is Docker and Why Use It for Python?

Docker is a platform that packages applications into lightweight, portable containers. A container includes your code, Python runtime, system libraries, and dependencies — everything needed to run your app. Unlike virtual machines, containers share the host OS kernel, making them start in seconds and use minimal resources.

For Python developers specifically, Docker solves several painful problems. It eliminates “works on my machine” issues by ensuring identical environments everywhere. It prevents dependency conflicts between projects without needing virtual environments on the host. It makes deployment as simple as shipping a single image file. And it lets you run different Python versions side by side without pyenv or system-level changes.

ConceptDockerVirtual Environment (venv)
Isolates Python packagesYesYes
Isolates system librariesYesNo
Isolates Python versionYesNo
Isolates OSYesNo
Portable across machinesYesNo
Adds overheadMinimal (~50MB base)None
Learning curveModerateLow

Think of Docker as a virtual environment on steroids — it does not just isolate your pip packages, it isolates the entire operating system layer. This makes it the standard tool for deploying Python applications in production.

Understanding Dockerfiles: Line by Line

A Dockerfile is a text file with instructions that tell Docker how to build your image. Each instruction creates a layer, and Docker caches these layers to speed up subsequent builds. Let us break down every line from our quick example:

# Dockerfile
FROM python:3.12-slim

WORKDIR /app

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

COPY . .

EXPOSE 5000

CMD ["python", "app.py"]

Line-by-line explanation:

FROM python:3.12-slim — This sets the base image. The slim variant includes Python and minimal system packages, keeping your image small (~150MB vs ~900MB for the full image). Always pin your Python version to avoid surprises when a new release comes out.

WORKDIR /app — Sets the working directory inside the container. All subsequent commands run from this path. If the directory does not exist, Docker creates it.

COPY requirements.txt . — Copies only the requirements file first. This is a deliberate optimization — Docker caches each layer, so if your requirements have not changed, it skips the pip install step on rebuild.

RUN pip install --no-cache-dir -r requirements.txt — Installs dependencies. The --no-cache-dir flag prevents pip from storing downloaded packages in the cache, reducing image size.

COPY . . — Copies the rest of your application code. This comes after pip install so that code changes do not trigger a full dependency reinstall.

EXPOSE 5000 — Documents which port the container listens on. This does not actually publish the port — you still need -p 5000:5000 when running the container.

CMD ["python", "app.py"] — The default command that runs when the container starts. Use the exec form (JSON array) rather than shell form for proper signal handling.

Choosing the Right Python Base Image

The base image you choose significantly affects your container’s size, security, and compatibility. Python offers several official variants on Docker Hub:

Image TagSizeIncludesBest For
python:3.12~900MBFull Debian + build toolsApps needing C compilation
python:3.12-slim~150MBMinimal DebianMost web apps (recommended)
python:3.12-alpine~50MBAlpine Linux (musl libc)Tiny images (watch for compatibility)
python:3.12-bookworm~900MBDebian Bookworm + build toolsSpecific Debian version needed

For most Python web applications, python:3.12-slim is the best starting point. It includes enough system libraries to install common packages like psycopg2-binary and Pillow without being bloated. The alpine variant looks attractive at 50MB, but it uses musl libc instead of glibc, which can cause subtle compatibility issues with some Python packages — especially those with C extensions like numpy or pandas.

When Alpine Actually Makes Sense

Alpine works well for pure-Python applications with no C extensions. If your app only uses packages like Flask, requests, and click, Alpine gives you the smallest possible image. But the moment you need numpy, pandas, or any package that compiles C code, you will spend more time fighting build issues than you save on image size.

# alpine_example.py
# This Dockerfile works great for pure-Python apps
# FROM python:3.12-alpine
# WORKDIR /app
# COPY requirements.txt .
# RUN pip install --no-cache-dir -r requirements.txt
# COPY . .
# CMD ["python", "app.py"]

# But if requirements.txt contains numpy, you need:
# RUN apk add --no-cache gcc musl-dev linux-headers
# This adds complexity and build time

Managing Dependencies in Docker

Proper dependency management is the difference between a Docker image that builds reliably and one that breaks randomly. The key principle is deterministic builds — every build should install the exact same package versions.

Pin Every Version

Never use unpinned requirements in Docker. A requirements.txt that says flask without a version will install whatever the latest version is at build time. This means your image built today might behave differently from one built tomorrow.

# requirements_pinned.py
# BAD - unpinned versions
# flask
# requests
# sqlalchemy

# GOOD - pinned versions
# flask==3.0.0
# requests==2.31.0
# sqlalchemy==2.0.23

# BEST - use pip freeze to capture exact versions
# pip freeze > requirements.txt

Output:

# Output of pip freeze (example)
blinker==1.7.0
certifi==2023.11.17
charset-normalizer==3.3.2
click==8.1.7
flask==3.0.0
idna==3.6
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.3
requests==2.31.0
urllib3==2.1.0
Werkzeug==3.0.1

Running pip freeze captures every installed package including transitive dependencies. This guarantees reproducible builds. For even more control, consider using pip-compile from the pip-tools package, which generates a locked requirements file from a high-level requirements.in.

Using .dockerignore

Just like .gitignore keeps files out of your repository, .dockerignore keeps files out of your Docker build context. Without it, Docker sends everything in your project directory to the Docker daemon, including large files that are not needed in the container.

# .dockerignore
__pycache__/
*.pyc
*.pyo
.git/
.gitignore
.env
.venv/
venv/
node_modules/
*.md
.pytest_cache/
.mypy_cache/
docker-compose*.yml
Dockerfile*
.dockerignore

This file reduces build context size and prevents sensitive files (like .env with secrets) from being copied into your image. Always create a .dockerignore file before building your first image.

Multi-Stage Builds for Smaller Images

Multi-stage builds are a Docker feature that lets you use multiple FROM statements in a single Dockerfile. This is powerful for Python because you can compile dependencies in a full build environment, then copy only the results into a slim runtime image.

# Dockerfile.multistage
# Stage 1: Build stage with full toolchain
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Runtime stage with minimal image
FROM python:3.12-slim
WORKDIR /app

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

# Copy application code
COPY . .

EXPOSE 5000
CMD ["python", "app.py"]

Output (comparing image sizes):

# Single-stage build
my-app-single    latest    892MB

# Multi-stage build
my-app-multi     latest    167MB

The multi-stage build produces an image that is over 5 times smaller. The first stage (builder) has gcc, make, and other build tools needed to compile C extensions. The second stage only includes the compiled packages and your code. Build tools, source files, and pip cache are all left behind in the builder stage.

This technique is especially valuable when your dependencies include packages like psycopg2 (needs libpq-dev), Pillow (needs libjpeg), or cryptography (needs OpenSSL headers). You compile in the full image and run in the slim image.

Docker Compose for Multi-Container Apps

Real applications rarely run in isolation. Your Python app probably needs a database, a cache layer, or a message queue. Docker Compose lets you define and run multi-container applications with a single YAML file.

# docker-compose.yml
version: "3.9"

services:
  web:
    build: .
    ports:
      - "5000:5000"
    environment:
      - DATABASE_URL=postgresql://user:password@db:5432/myapp
      - REDIS_URL=redis://cache:6379/0
    depends_on:
      - db
      - cache
    volumes:
      - .:/app

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:

Output (docker compose up):

$ docker compose up
[+] Running 3/3
 - Container myapp-db-1     Started
 - Container myapp-cache-1  Started
 - Container myapp-web-1    Started
Attaching to myapp-cache-1, myapp-db-1, myapp-web-1
myapp-db-1     | PostgreSQL init process complete; ready for start up.
myapp-cache-1  | Ready to accept connections
myapp-web-1    |  * Running on http://0.0.0.0:5000

With one docker compose up command, you get a Python web app, PostgreSQL database, and Redis cache all running together. The depends_on directive ensures the database starts before your app. The volumes section persists database data between restarts and mounts your source code for live reloading during development.

Production Best Practices

Development Dockerfiles and production Dockerfiles have different priorities. In development, you want fast rebuilds and live reloading. In production, you want small images, security, and reliability.

Run as Non-Root User

By default, containers run as root. This is a security risk — if an attacker exploits your app, they have root access inside the container. Always create and switch to a non-root user:

# Dockerfile.production
FROM python:3.12-slim

# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser

WORKDIR /app

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

COPY . .

# Change ownership and switch user
RUN chown -R appuser:appuser /app
USER appuser

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

Notice we also switched from the Flask development server to Gunicorn for production. The Flask dev server is single-threaded and not designed for production traffic. Gunicorn runs multiple worker processes to handle concurrent requests.

Add Health Checks

Health checks tell Docker (and orchestrators like Kubernetes) whether your application is actually working, not just running:

# Dockerfile.healthcheck
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

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

EXPOSE 5000
CMD ["python", "app.py"]

Output (docker inspect):

$ docker inspect --format='{{.State.Health.Status}}' my-container
healthy

The health check hits your /health endpoint every 30 seconds. If it fails 3 times in a row, Docker marks the container as unhealthy. Orchestrators can then automatically restart it or route traffic elsewhere.

Handle Secrets with Environment Variables

Never bake secrets into your Docker image. Anyone who pulls your image can extract them. Use environment variables instead:

# config.py
import os

DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///local.db")
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-secret")
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"

print(f"Database: {DATABASE_URL.split('@')[-1] if '@' in DATABASE_URL else DATABASE_URL}")
print(f"Debug mode: {DEBUG}")

Output:

Database: db:5432/myapp
Debug mode: False

Pass environment variables at runtime with docker run -e SECRET_KEY=mysecret or through Docker Compose’s environment section. For sensitive values in production, use Docker secrets or your cloud provider’s secrets manager.

Real-Life Example: Dockerized Task Tracker API

Let us build a complete, production-ready Dockerized application — a task tracker API with Flask, SQLite, and proper project structure:

# task_tracker.py
from flask import Flask, request, jsonify
import sqlite3
import os
from datetime import datetime

app = Flask(__name__)
DB_PATH = os.environ.get("DB_PATH", "/app/data/tasks.db")


def get_db():
    os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn


def init_db():
    db = get_db()
    db.execute("""
        CREATE TABLE IF NOT EXISTS tasks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            description TEXT DEFAULT '',
            completed BOOLEAN DEFAULT 0,
            created_at TEXT DEFAULT CURRENT_TIMESTAMP
        )
    """)
    db.commit()
    db.close()


@app.route("/health")
def health():
    return {"status": "healthy", "timestamp": datetime.now().isoformat()}


@app.route("/tasks", methods=["GET"])
def list_tasks():
    db = get_db()
    tasks = db.execute("SELECT * FROM tasks ORDER BY created_at DESC").fetchall()
    db.close()
    return jsonify([dict(t) for t in tasks])


@app.route("/tasks", methods=["POST"])
def create_task():
    data = request.get_json()
    if not data or not data.get("title"):
        return {"error": "Title is required"}, 400

    db = get_db()
    cursor = db.execute(
        "INSERT INTO tasks (title, description) VALUES (?, ?)",
        (data["title"], data.get("description", ""))
    )
    db.commit()
    task_id = cursor.lastrowid
    task = db.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
    db.close()
    return jsonify(dict(task)), 201


@app.route("/tasks/<int:task_id>/complete", methods=["PATCH"])
def complete_task(task_id):
    db = get_db()
    db.execute("UPDATE tasks SET completed = 1 WHERE id = ?", (task_id,))
    db.commit()
    task = db.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
    db.close()
    if task is None:
        return {"error": "Task not found"}, 404
    return jsonify(dict(task))


init_db()

if __name__ == "__main__":
    port = int(os.environ.get("PORT", 5000))
    app.run(host="0.0.0.0", port=port, debug=os.environ.get("DEBUG") == "true")

Output (testing the API):

$ curl -X POST http://localhost:5000/tasks \
    -H "Content-Type: application/json" \
    -d '{"title": "Learn Docker", "description": "Complete the tutorial"}'
{
  "id": 1,
  "title": "Learn Docker",
  "description": "Complete the tutorial",
  "completed": 0,
  "created_at": "2026-04-08 10:30:00"
}

$ curl http://localhost:5000/tasks
[
  {
    "id": 1,
    "title": "Learn Docker",
    "description": "Complete the tutorial",
    "completed": 0,
    "created_at": "2026-04-08 10:30:00"
  }
]

$ curl http://localhost:5000/health
{"status": "healthy", "timestamp": "2026-04-08T10:30:15.123456"}

This application demonstrates several production patterns: health check endpoint for container orchestration, environment variable configuration, proper database initialization, input validation, and error handling. The Docker setup uses volumes to persist the SQLite database and a non-root user for security. You can extend this by adding authentication, switching to PostgreSQL via Docker Compose, or deploying to a cloud container service.

Essential Docker Commands Reference

CommandWhat It Does
docker build -t name .Build image from Dockerfile in current directory
docker run -p 5000:5000 nameRun container, map port 5000
docker run -d nameRun container in background (detached)
docker psList running containers
docker logs container_idView container logs
docker exec -it container_id bashOpen shell inside running container
docker stop container_idStop a running container
docker imagesList all local images
docker system pruneRemove unused images, containers, networks
docker compose upStart all services in docker-compose.yml
docker compose downStop and remove all services

Frequently Asked Questions

Do I still need a virtual environment inside Docker?

No. The Docker container itself provides isolation, so there is no risk of conflicting with system Python or other projects. Some teams still use venv inside Docker for consistency with their local development workflow, but it adds no practical benefit. Skip it and install directly with pip install in your Dockerfile.

Why is my Docker build so slow?

The most common cause is poor layer caching. If you COPY . . before RUN pip install, any code change invalidates the pip install cache. Always copy requirements.txt first and install dependencies before copying the rest of your code. Also check that your .dockerignore excludes large directories like .git/, node_modules/, and __pycache__/.

Should I use Alpine Linux for my Python Docker image?

Only if your app uses pure-Python packages with no C extensions. Alpine uses musl libc instead of glibc, which causes build failures and subtle runtime issues with packages like numpy, pandas, and psycopg2. The slim variant is only ~100MB larger than Alpine and avoids these compatibility headaches entirely.

How do I reduce my Docker image size?

Use multi-stage builds to separate build tools from your runtime image. Use python:3.12-slim as your runtime base. Add --no-cache-dir to pip install commands. Create a thorough .dockerignore file. Remove unnecessary system packages with apt-get clean and rm -rf /var/lib/apt/lists/* after installing system dependencies.

What is the difference between Dockerfile and docker-compose.yml?

A Dockerfile defines how to build a single image — what base to start from, what to install, what to copy, and what command to run. Docker Compose defines how to run multiple containers together — which images to use, how they connect, what ports to expose, and what volumes to mount. You need a Dockerfile for each custom service and a docker-compose.yml to orchestrate them all.

How do I get hot-reload working in Docker during development?

Mount your source code as a volume so changes on your host are reflected inside the container immediately. In your docker-compose.yml, add volumes: [".:/app"] under your web service. Then run Flask with debug=True or use a tool like watchdog to restart on file changes. This gives you the fast feedback loop of local development with the consistency of Docker.

Conclusion

You have learned how to containerize Python applications with Docker from scratch. We covered writing Dockerfiles with proper layer caching, choosing the right base image, pinning dependencies for reproducible builds, using multi-stage builds to shrink image size, orchestrating multi-container apps with Docker Compose, and following production best practices like non-root users and health checks.

Try extending the task tracker example by adding PostgreSQL via Docker Compose, or deploy it to a cloud service like AWS ECS, Google Cloud Run, or Railway. The skills you have learned here apply to any Python application — from simple scripts to complex microservice architectures.

For the official Docker documentation, visit docs.docker.com. For Python-specific Docker guidance, see the official Python Docker images documentation.