Skip to content

Dockerizing a Python App

If you have containerized a Node/TypeScript app before, you know the pattern: copy package.json, run npm ci, copy source, build, and run. Python’s Dockerfile follows the same multi-stage logic — but with a few Python-specific best practices you need to internalize.

TypeScript
# ---- Node / TypeScript multi-stage Dockerfile ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "dist/main.js"]
Python
# ---- Python multi-stage Dockerfile ----
FROM python:3.14-slim AS builder
WORKDIR /app
# Install build tools, then create venv inside the image
RUN python -m venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.14-slim AS runner
WORKDIR /app
# Copy the pre-built venv from builder
COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
# Copy application source
COPY src/ ./src/
# Run as non-root user
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

If the project uses uv (see venv, pip, uv and Poetry), copy the uv binary from its official image and let it build the environment from the lock file. This is the fastest option and keeps the runtime image free of build tooling.

TypeScript
# ---- Node / TypeScript multi-stage Dockerfile ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "dist/main.js"]
Python
# ---- uv multi-stage Dockerfile ----
FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
# Install dependencies first — cached unless the lock file changes
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-install-project --no-dev
COPY src/ ./src/
RUN uv sync --locked --no-dev
FROM python:3.14-slim AS runner
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src ./src
ENV PATH="/app/.venv/bin:$PATH"
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

--locked makes the build fail if uv.lock is out of date rather than silently resolving new versions — the equivalent of npm ci refusing to drift from package-lock.json.

Here is the production-grade Dockerfile with comments:

# syntax=docker/dockerfile:1
# ---- Stage 1: Build / dependency installation ----
FROM python:3.14-slim AS builder
WORKDIR /app
# PYTHONDONTWRITEBYTECODE: don't write .pyc files (saves space)
# PYTHONUNBUFFERED: log output immediately (12-Factor: log to stdout)
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Create the virtual environment INSIDE the image
# This lets us copy the entire /app/.venv to the final stage
RUN python -m venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
# Copy dependency manifest first (Docker layer caching)
COPY pyproject.toml uv.lock* requirements.txt* ./
# Install dependencies — no cache to keep image small
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# ---- Stage 2: Runtime image ----
FROM python:3.14-slim AS runner
WORKDIR /app
# Copy the fully-populated venv from builder
COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Copy application source (after deps — better cache hit on rebuilds)
COPY src/ ./src/
# Create and switch to a non-root user (security best practice)
RUN addgroup --system appgroup && \
adduser --system --ingroup appgroup --no-create-home appuser
USER appuser
# Expose the port the app listens on
EXPOSE 8000
# CMD uses exec form (no shell wrapper — signals go directly to process)
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

Run this in your terminal to build and run:

Terminal window
docker build -t my-python-app .
docker run -p 8000:8000 my-python-app
# Or with docker compose:
# docker compose up --build

Like .gitignore for Docker. Always create one to avoid copying your venv, cache, and secrets into the image:

.venv/
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.mypy_cache/
.ruff_cache/
.env
.env.*
*.log
Why should you create a virtual environment INSIDE the Docker image instead of relying on the system Python?
What does PYTHONUNBUFFERED=1 ensure in a containerized Python app?
In a multi-stage Dockerfile, how do you bring the installed packages from the builder stage into the runner stage?