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.
| Concept | Docker | Virtual Environment (venv) |
|---|---|---|
| Isolates Python packages | Yes | Yes |
| Isolates system libraries | Yes | No |
| Isolates Python version | Yes | No |
| Isolates OS | Yes | No |
| Portable across machines | Yes | No |
| Adds overhead | Minimal (~50MB base) | None |
| Learning curve | Moderate | Low |
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 Tag | Size | Includes | Best For |
|---|---|---|---|
python:3.12 | ~900MB | Full Debian + build tools | Apps needing C compilation |
python:3.12-slim | ~150MB | Minimal Debian | Most web apps (recommended) |
python:3.12-alpine | ~50MB | Alpine Linux (musl libc) | Tiny images (watch for compatibility) |
python:3.12-bookworm | ~900MB | Debian Bookworm + build tools | Specific 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
| Command | What It Does |
|---|---|
docker build -t name . | Build image from Dockerfile in current directory |
docker run -p 5000:5000 name | Run container, map port 5000 |
docker run -d name | Run container in background (detached) |
docker ps | List running containers |
docker logs container_id | View container logs |
docker exec -it container_id bash | Open shell inside running container |
docker stop container_id | Stop a running container |
docker images | List all local images |
docker system prune | Remove unused images, containers, networks |
docker compose up | Start all services in docker-compose.yml |
docker compose down | Stop 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.