Docker is one of those tools that everyone uses and very few people use well. It’s possible to write a working Dockerfile in 5 minutes; it’s also possible to ship a 2.4 GB image that takes 90 seconds to start, leaks secrets, and breaks every time a base image updates.
This post is the practical Docker guide for Python developers I wish I’d had when I started. We’ll cover the Dockerfile patterns that work, multi-stage builds, docker-compose for local dev, and the small habits that make the difference between “works on my laptop” and “works in production.”
Why containers, briefly
A container packages your code with a frozen environment — Python version, system packages, dependencies, files — so that “works on my machine” works on every machine. Compared to VMs, containers are lightweight (they share the host kernel), start in milliseconds, and ship via images that are easy to version and pull.
For Python developers specifically, containers solve:
- “It works with Python 3.11 but the server has 3.10.”
- “The package needs a system library I forgot to install.”
- “How do we run Postgres locally without installing Postgres?”
Your first Dockerfile (the bad one)
A naive Dockerfile:
FROM python:3.13
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
This works. But it has problems:
- Uses
python:3.13(Debian-based, ~1 GB image) instead ofpython:3.13-slim. COPY . .before installing dependencies, so every code change busts the dependency layer cache.- Runs as
root. - No
.dockerignore, so it ships your.git,__pycache__,.venv, etc. - No multi-stage, so build tools live in the final image.
Let’s fix all of that.
A better Dockerfile
# syntax=docker/dockerfile:1.7
FROM python:3.13-slim AS base
# Don't write .pyc files; flush stdout immediately.
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Install system deps that runtime needs (not build tools).
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# ---- Builder stage: install Python deps ---- #
FROM base AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# ---- Final stage: copy from builder ---- #
FROM base AS final
# Create a non-root user
RUN useradd --create-home --shell /bin/bash app
USER app
WORKDIR /home/app
# Pull in installed packages from the builder
COPY --from=builder /root/.local /home/app/.local
ENV PATH=/home/app/.local/bin:$PATH
# Copy app code last (so dep changes don't invalidate this layer)
COPY --chown=app:app . .
EXPOSE 8000
CMD ["gunicorn", "app.main:app", "--bind", "0.0.0.0:8000", "--workers", "4"]
What changed:
python:3.13-slim— ~150 MB instead of ~1 GB.- Multi-stage build —
build-essentialandlibpq-devare only in the builder stage. The final image has only the runtime libs. COPY requirements.txtbeforeCOPY .— dep installs are cached unlessrequirements.txtchanges.- Non-root user — running as
rootinside a container is a security smell. PYTHONUNBUFFERED=1— Python’s stdout flushes immediately, so logs don’t disappear on crash.
Build and check the size:
docker build -t myapp .
docker images myapp
Should be a few hundred MB instead of 1+ GB.
.dockerignore
This file does for Docker what .gitignore does for git. Without it, COPY . . copies your .git, .venv, build artifacts, IDE files, and any local caches.
# .dockerignore
.git
.gitignore
.venv
venv
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.mypy_cache
.ruff_cache
.coverage
htmlcov
.env
.env.*
*.log
.DS_Store
.idea
.vscode
node_modules
build
dist
*.egg-info
Adding .dockerignore typically halves image build time and image size.
Using uv instead of pip
If you’re using uv
, the Dockerfile is even cleaner and much faster:
FROM python:3.13-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
# Install uv from its official image
COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /usr/local/bin/uv
# Copy lockfiles first for cache
COPY pyproject.toml uv.lock ./
# Install deps to a system path
RUN uv sync --frozen --no-install-project --no-dev
# Now copy the app
COPY . .
RUN uv sync --frozen --no-dev
CMD ["uv", "run", "gunicorn", "app.main:app", "--bind", "0.0.0.0:8000"]
uv makes installs 10–50× faster, and the lockfile (uv.lock) gives you fully reproducible builds.
docker-compose for local development
For local dev you usually want your app + a database + maybe a Redis. docker-compose runs them as a unit:
# docker-compose.yml
services:
app:
build: .
ports:
- "8000:8000"
volumes:
- .:/home/app
environment:
DATABASE_URL: postgresql+asyncpg://tasksuser:tasksdbpass@db:5432/tasksdb
REDIS_URL: redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: tasksuser
POSTGRES_PASSWORD: tasksdbpass
POSTGRES_DB: tasksdb
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tasksuser -d tasksdb"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_data:
Bring it up:
docker compose up
Hit http://localhost:8000, edit your code on the host, see the changes reload (because --reload is on and the volume mount syncs files). When you’re done:
docker compose down # stop containers
docker compose down -v # also delete the database volume
This is by far the lowest-friction way to give a new developer everything they need to run your app — git clone && docker compose up.
Image tagging and registries
In production, never pull latest. Tag explicitly:
docker build -t registry.example.com/myapp:v1.2.3 .
docker push registry.example.com/myapp:v1.2.3
Common tagging strategies:
- Git SHA —
myapp:abc1234. Fully traceable. - Semver —
myapp:1.2.3. Plusmyapp:1.2,myapp:1aliases for convenience. - Date —
myapp:2026-04-30. Easy to read, less easy to roll back.
Most teams combine: SHA tag for traceability, semver tag for humans.
Health checks
Docker can ping your container to know if it’s actually healthy (not just running):
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
For an even better signal, expose a /health endpoint that pings the database:
# FastAPI / Django pattern
@app.get("/health")
async def health(db: AsyncSession = Depends(get_db)):
await db.execute(text("SELECT 1"))
return {"status": "ok"}
If the DB is down, the container is unhealthy and your orchestrator can do something about it.
Secrets and config
Bake config that’s not secret into the image (e.g., feature flags). Pass secrets at runtime via environment variables, Docker secrets, or a secret manager.
Never COPY .env into the image. Never ENV SECRET_KEY=… in the Dockerfile (it ends up in the image history forever).
Use docker-compose’s env_file: (for local dev) or your orchestrator’s secret mechanism (Kubernetes Secrets, AWS Secrets Manager, Hashicorp Vault) in production.
Common mistakes
- Running
pip installwithout--no-cache-dir. Wastes ~50–100 MB per image. - Installing build tools in the final image. Multi-stage prevents this.
- Running as root — use
USER. - Using
python:3.13instead ofpython:3.13-slim— gigabyte images for no benefit. - Mounting your venv as a volume — defeats the point. Build deps into the image.
- No
.dockerignore— slow builds and bloated images. COPY .before installing deps — busts the cache on every code change.
A production-ready FastAPI Dockerfile (full example)
Putting it all together:
# syntax=docker/dockerfile:1.7
FROM python:3.13-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# ----- builder ----- #
FROM base AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
# ----- final ----- #
FROM base AS final
RUN useradd --create-home --shell /bin/bash app
USER app
WORKDIR /home/app
COPY --from=builder /app/.venv /home/app/.venv
ENV PATH=/home/app/.venv/bin:$PATH
COPY --chown=app:app . .
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
Build it, ship it, and you have a small, fast, secure-enough Python container.
Conclusion
Docker isn’t hard, but it’s full of small choices that compound into either a great or a frustrating experience. Multi-stage builds, slim base images, .dockerignore, non-root users, and smart layer ordering — those five habits will put you ahead of most teams.
If you’re deploying Django or FastAPI, the Deploying Django to Production post complements this one well. And if you haven’t picked a packaging tool yet, see Python Virtual Environments: uv vs venv vs Poetry .
Happy containerizing!
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .