Dockerizing a Python App
The Node Dockerfile you already know
Section titled “The Node Dockerfile you already know”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.
Side-by-side: Node vs Python Dockerfile
Section titled “Side-by-side: Node vs Python Dockerfile”# ---- Node / TypeScript multi-stage Dockerfile ----FROM node:20-alpine AS depsWORKDIR /appCOPY package*.json ./RUN npm ci --only=production
FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
FROM node:20-alpine AS runnerWORKDIR /appENV NODE_ENV=productionCOPY --from=deps /app/node_modules ./node_modulesCOPY --from=builder /app/dist ./distRUN addgroup -S appgroup && adduser -S appuser -G appgroupUSER appuserEXPOSE 3000CMD ["node", "dist/main.js"]# ---- Python multi-stage Dockerfile ----FROM python:3.14-slim AS builderWORKDIR /app
# Install build tools, then create venv inside the imageRUN python -m venv /app/.venvENV PATH="/app/.venv/bin:$PATH"
COPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.14-slim AS runnerWORKDIR /app
# Copy the pre-built venv from builderCOPY --from=builder /app/.venv /app/.venvENV PATH="/app/.venv/bin:$PATH"
# Copy application sourceCOPY src/ ./src/
# Run as non-root userRUN addgroup --system appgroup && adduser --system --ingroup appgroup appuserUSER appuser
EXPOSE 8000CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]The uv variant
Section titled “The uv variant”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.
# ---- Node / TypeScript multi-stage Dockerfile ----FROM node:20-alpine AS depsWORKDIR /appCOPY package*.json ./RUN npm ci --only=production
FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
FROM node:20-alpine AS runnerWORKDIR /appENV NODE_ENV=productionCOPY --from=deps /app/node_modules ./node_modulesCOPY --from=builder /app/dist ./distRUN addgroup -S appgroup && adduser -S appuser -G appgroupUSER appuserEXPOSE 3000CMD ["node", "dist/main.js"]# ---- uv multi-stage Dockerfile ----FROM python:3.14-slim AS builderCOPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uvWORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
# Install dependencies first — cached unless the lock file changesCOPY 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 runnerWORKDIR /appCOPY --from=builder /app/.venv /app/.venvCOPY --from=builder /app/src ./srcENV PATH="/app/.venv/bin:$PATH"
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuserUSER appuser
EXPOSE 8000CMD ["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.
The full Python Dockerfile explained
Section titled “The full Python Dockerfile explained”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 stageRUN python -m venv /app/.venvENV 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 smallRUN 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 builderCOPY --from=builder /app/.venv /app/.venvENV 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 appuserUSER appuser
# Expose the port the app listens onEXPOSE 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"]Why venv inside the Docker image?
Section titled “Why venv inside the Docker image?”Run this in your terminal to build and run:
docker build -t my-python-app .docker run -p 8000:8000 my-python-app
# Or with docker compose:# docker compose up --build.dockerignore
Section titled “.dockerignore”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