Python with Docker means packaging a Python application — your code, the exact interpreter version, and every dependency — into a self-contained container image that runs identically on a laptop, a CI runner, or production. You containerize Python apps to get reproducible builds, dev/prod parity, and one-command deploys, so "works on my machine" stops being a debugging session.
This guide is a practical, current (2026) walkthrough: why Docker fits Python, the core concepts, a production-grade multi-stage Dockerfile using python:3.12-slim and a non-root user, running a Django or FastAPI stack with Docker Compose v2, plus the best practices and pitfalls we've learned shipping containerized Python for clients over 12+ years and 50+ projects.
If you just want the short version: use a slim base image, build in multiple stages, install dependencies before copying source (for layer caching), never run as root, keep secrets out of the image, and run your app with Gunicorn or Uvicorn behind a real process manager in production.
Why use Docker for Python?
Python's runtime is famously environment-sensitive. The interpreter version, C extension build tools, system libraries (libpq, libjpeg, ffmpeg), and the exact pinned package set all affect whether your code runs. Docker captures all of that as an immutable image.
- Reproducibility — A pinned image (
python:3.12-slim+ a locked requirements file) builds the same bytes today and next year. No more "it broke after someone upgraded OpenSSL." - Dev/prod parity — The container you test locally is the artifact you deploy. CI builds it once and promotes the same image through staging to production.
- Isolation — Each service gets its own dependency tree. A project on Python 3.11 and another on 3.13 coexist with zero conflicts, no
pyenvjuggling. - Easy onboarding —
docker compose upand a new teammate has Postgres, Redis, and the app running in minutes — no 40-step README. - Portable deploys — The same image runs on AWS ECS/Fargate, EKS/Kubernetes, Google Cloud Run, or a single VM. This portability is what makes container-based cloud migration low-risk.
Docker vs virtualenv: when do you still need a venv?
A virtual environment isolates Python packages; a container isolates the entire operating system layer. They solve overlapping but different problems.
| Concern | virtualenv / uv venv | Docker container |
|---|---|---|
| Isolates Python packages | Yes | Yes |
| Isolates system libraries & OS | No | Yes |
| Pins the Python interpreter | No (uses host Python) | Yes (baked into the image) |
| Reproducible on another machine | Partially | Yes |
| Ships to production as one artifact | No | Yes |
| Startup overhead | None | Negligible (shared kernel) |
For local hacking on a single script, a venv is faster. For anything you deploy, ship a container. Many teams use both: a venv for fast inner-loop edits, Docker as the build-and-ship boundary.
Core Docker concepts for Python developers
A few terms you'll see throughout:
- Image — A read-only template (your app + dependencies + base OS layer). Built from a
Dockerfile. Immutable and versioned by tag (myapp:1.4.0). - Container — A running instance of an image. You can start many containers from one image; each is disposable.
- Dockerfile — The recipe. Each instruction (
FROM,COPY,RUN) creates a cached layer, so ordering matters for build speed. - Registry — Where images live: Docker Hub, Amazon ECR, GitHub Container Registry (GHCR), Google Artifact Registry. CI pushes here; production pulls from here.
- Layer caching — Docker reuses unchanged layers between builds. Put rarely-changing steps (dependency install) before fast-changing ones (copying source) so a code edit doesn't reinstall every package.
- Multi-stage build — Use one stage to compile/install, then copy only the finished artifacts into a small final image. This is the single biggest lever for small, secure Python images.
A modern, production-grade Python Dockerfile
The classic 2014-era tutorial Dockerfile — FROM ubuntu, apt-get install python, pip install as root, single stage — produces a 1 GB+ image that runs your web app with full root privileges. Don't ship that.
Here is a current multi-stage build. It uses python:3.12-slim, installs dependencies in a builder stage, copies only what's needed into a lean runtime stage, and runs as a non-root user.
# syntax=docker/dockerfile:1
# ---------- Stage 1: builder ----------
FROM python:3.12-slim AS builder
# Don't write .pyc files; flush stdout/stderr immediately for clean logs.
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# Build-time system deps for compiling wheels (e.g. psycopg, Pillow).
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies into a venv FIRST so this layer is cached
# until requirements actually change.
COPY requirements.txt .
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install -r requirements.txt
# ---------- Stage 2: runtime ----------
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
# Runtime-only system libs (no compilers in the final image).
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 1000 appuser
WORKDIR /app
# Copy the prepared virtualenv from the builder stage.
COPY --from=builder /opt/venv /opt/venv
# Copy application source last (changes most often).
COPY --chown=appuser:appuser . .
# Drop root privileges.
USER appuser
EXPOSE 8000
# Container-native health signal.
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health/').status==200 else 1)"
# Production server, not the dev server. Tune workers to ~2*CPU+1.
CMD ["gunicorn", "myapp.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]Speeding up installs with uv
uv is a fast Rust-based Python package installer that's become a common default in 2025–2026. It resolves and installs dependencies far quicker than pip, which matters in CI. Swap the builder install steps for:
COPY pyproject.toml uv.lock .
RUN pip install uv && uv sync --frozen --no-dev
uv also ships official slim images (ghcr.io/astral-sh/uv) if you prefer not to install it yourself. The multi-stage pattern is identical — only the resolver changes.
Always add a .dockerignore
Without it, COPY . . drags your .git history, local venv, caches, and secrets into the image, bloating it and leaking data. Mirror your .gitignore:
# .dockerignore
.git
.gitignore
__pycache__/
*.pyc
.venv/
venv/
.env
.env.*
.pytest_cache/
.mypy_cache/
.ruff_cache/
node_modules/
*.sqlite3
.DS_Store
docs/
tests/Running a stack with Docker Compose v2
Real Python apps need a database, often a cache and a worker. Docker Compose v2 (invoked as docker compose, with a space — the old docker-compose script is deprecated) defines the whole stack in one file. Note: the version: key at the top is obsolete in Compose v2 and can be removed.
Here's a compose.yaml for a Django or FastAPI app with Postgres. For Django, the command runs migrations then the server; for FastAPI, swap in uvicorn.
# compose.yaml (Compose v2 — no "version:" key needed)
services:
web:
build: .
# Django:
command: sh -c "python manage.py migrate && gunicorn myapp.wsgi:application --bind 0.0.0.0:8000 --workers 3"
# FastAPI alternative:
# command: uvicorn app.main:app --host 0.0.0.0 --port 8000
ports:
- "8000:8000"
env_file:
- .env # secrets live here, NOT in the image
depends_on:
db:
condition: service_healthy
develop:
watch: # live sync source in dev (Compose v2)
- action: sync
path: ./app
target: /app/app
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data # named volume, survives restarts
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:Everyday Compose v2 commands
A quick reference for the day-to-day loop:
# Build and start the whole stack in the background
docker compose up -d --build
# Tail logs for one service
docker compose logs -f web
# Run a one-off management command (Django)
docker compose run --rm web python manage.py createsuperuser
# Open a shell inside the running web container
docker compose exec web bash
# Rebuild just the web image after dependency changes
docker compose build web
# Stop and remove containers (keep named volumes / data)
docker compose down
# Same, but also delete volumes (DESTROYS the database)
docker compose down -vBest practices for production Python images
- Keep images small. Multi-stage builds + slim base + a
.dockerignoretypically take a Python web image from ~1 GB down to 150–250 MB. Smaller images pull faster, cost less, and have a smaller attack surface. - Never bake secrets into the image. Image layers are inspectable — anyone with the image can read a baked-in
API_KEY. Inject secrets at runtime via environment variables, an.envfile (kept out of the image), or a secrets manager (AWS Secrets Manager, SSM Parameter Store). - Run as a non-root user. A
USER appuserline limits the blast radius if the app is compromised. Many orchestrators (and some registries' scanners) flag root containers. - Pin everything. Pin the base image (
python:3.12-slim, ideally by digest) and lock dependencies (requirements.txtwith hashes, oruv.lock). Floating tags likepython:latestmake builds non-deterministic. - Add a HEALTHCHECK. Lets Docker, Compose, and orchestrators know when the app is actually ready vs merely started.
- Order layers for cache hits. Copy and install dependencies before copying source. A one-line code change should rebuild in seconds, not reinstall every wheel.
- One process per container. Run the web server, the Celery/RQ worker, and the scheduler as separate services so each scales independently.
- Scan and update. Run
docker scoutor Trivy in CI and rebuild on a cadence to pick up base-image security patches.
Choosing a base image: slim vs alpine vs full
| Base image | Approx size | Best for | Watch out for |
|---|---|---|---|
python:3.12-slim |
~120 MB | Default choice for almost all Python apps | Add the few system libs you actually need |
python:3.12-alpine |
~50 MB | Tiny images, simple pure-Python apps | musl libc breaks many binary wheels → slow source builds, subtle C-extension bugs |
python:3.12 (full) |
~1 GB | Heavy build toolchains, quick experiments | Far too large for production runtime |
| Distroless / chiselled | ~50–80 MB | Hardened, minimal-attack-surface deploys | No shell to debug; advanced setup |
Recommendation: start with slim. Alpine's smaller size is tempting, but musl libc means many C extensions (NumPy, Pillow, psycopg) lack prebuilt wheels and must compile from source — slower builds and occasional runtime surprises. The size you save rarely justifies the debugging time.
Production notes
- Use a real WSGI/ASGI server, never the dev server.
python manage.py runserveranduvicorn --reloadare for development only. In production run Django under Gunicorn (often with Uvicorn workers for ASGI) and run FastAPI under Uvicorn or Gunicorn + Uvicorn workers. Set worker count to roughly2 × CPU cores + 1and tune from there. - Configure with environment variables. Read settings (
DATABASE_URL,SECRET_KEY,DEBUG) from the environment so the same image runs in every environment with different config — the twelve-factor approach. - Persist state in volumes, not the container. Containers are ephemeral; their filesystem vanishes on recreate. Databases and uploaded media belong in named volumes or managed services (RDS, S3), never in the image or container layer.
- Serve static files properly. Don't let the app server serve static assets in production — use WhiteNoise, a CDN, or object storage + CloudFront.
- Centralize logs. Log to stdout/stderr (as the Dockerfile's
PYTHONUNBUFFERED=1ensures) and let the platform collect them — don't write log files inside the container.
Bind mount vs named volume
| Bind mount | Named volume | |
|---|---|---|
| Maps to | A host directory you choose | Docker-managed storage |
| Typical use | Live-reloading source in development | Persistent production data (Postgres, uploads) |
| Portability | Tied to host paths | Portable across hosts |
| Performance | Can be slow on macOS/Windows | Native speed |
Use bind mounts (or Compose's develop.watch) for the dev inner loop; use named volumes or managed storage for anything that must survive a redeploy.
Common pitfalls (and fixes)
- Running as root. The default if you omit
USER. Add a non-root user — see the Dockerfile above. COPY . .before installing deps. Breaks layer caching, so every code edit reinstalls all packages. Copyrequirements.txt/uv.lockand install first.- Forgetting
.dockerignore. Leaks.git, local.venv, and.envsecrets into the image and bloats it. - Storing the database in the container. Data is destroyed on
docker compose downwithout a named volume. Always use a volume or managed DB. - Using the dev server in production.
runserver/--reloadare single-threaded and insecure. Use Gunicorn/Uvicorn. - Choosing alpine reflexively. musl libc breaks binary wheels for many scientific/DB packages. Prefer
slimunless you've measured a real benefit. pip installwithout a lock. Floating versions make "it built yesterday" meaningless. Pin and lock.- Mixing
docker-compose(v1) anddocker compose(v2). Standardize on v2 (docker compose); v1 is end-of-life.
For deeper, framework-specific walkthroughs see how to deploy a Django project into a Docker container and why choose Python for backend development. For multi-container orchestration at scale, clustering Docker containers with Docker Swarm and CI/CD with GitLab and Docker build on these foundations. When you need a hand, our team offers Python development services end to end.
Frequently Asked Questions
Why use Docker for Python?
Docker gives Python apps reproducible builds and dev/prod parity. Packaging the interpreter version, system libraries, and locked dependencies into one image means the artifact you test locally is exactly what runs in production — eliminating "works on my machine" issues and making deploys a single, repeatable step across any host or cloud.
What's the best base image for Python in Docker?
For almost all Python web apps, python:3.12-slim is the best default: it's small (~120 MB) yet uses glibc, so prebuilt wheels for packages like NumPy, Pillow, and psycopg just work. Avoid the full python:3.12 image (~1 GB) in production, and approach alpine with caution because its musl libc breaks many binary wheels and forces slow source compilation.
Should I run Python as a non-root user in Docker?
Yes. Running as root means a compromised app has root inside the container, widening the blast radius of any exploit. Create a dedicated user with useradd and add a USER appuser line near the end of the Dockerfile. It is a one-line change that most security scanners and orchestrators expect.
Docker vs virtualenv — which should I use?
They solve different problems. A virtualenv isolates Python packages but still relies on the host's interpreter and system libraries. Docker isolates the entire OS layer, pins the Python version, and produces a single deployable artifact. Use a venv for quick local edits; use Docker as your build-and-ship boundary for anything you deploy. Many teams use both.
How do I use Docker Compose with Django or FastAPI?
Define a compose.yaml with a web service built from your Dockerfile and a db service such as postgres:16-alpine, wire them with depends_on plus a healthcheck, and store secrets in an .env file referenced via env_file. Run Django under Gunicorn and FastAPI under Uvicorn, then docker compose up -d --build brings the whole stack online. This is Compose v2 (docker compose, no version: key).
How do I make my Python Docker image smaller?
Use a multi-stage build (compile and install in a builder stage, copy only the finished virtualenv into a slim runtime stage), start from python:3.12-slim, add a .dockerignore to exclude .git, caches, and .env, install only runtime system libraries in the final stage, and avoid leaving build tools like build-essential in production. Together these typically cut a Python web image from ~1 GB to 150–250 MB.