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 of python: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:

  1. python:3.13-slim — ~150 MB instead of ~1 GB.
  2. Multi-stage buildbuild-essential and libpq-dev are only in the builder stage. The final image has only the runtime libs.
  3. COPY requirements.txt before COPY . — dep installs are cached unless requirements.txt changes.
  4. Non-root user — running as root inside a container is a security smell.
  5. 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 SHAmyapp:abc1234. Fully traceable.
  • Semvermyapp:1.2.3. Plus myapp:1.2, myapp:1 aliases for convenience.
  • Datemyapp: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 install without --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.13 instead of python: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 .