TL;DR

Use multi-stage builds to keep production images small; run as non-root; pin base image digests; use .dockerignore to avoid leaking secrets. A small, minimal image is faster to pull, cheaper to scan, and has a smaller attack surface.

Python Multi-stage Build

Multi-stage builds separate the build environment (full Python + build tools) from the production image (only the installed packages and app code), reducing image size by 60–80%.

bashDockerfile.python
# Stage 1: build — install all deps including build-time tools
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: production — copy only installed packages and app code
FROM python:3.12-slim AS production
WORKDIR /app

# Run as non-root user
RUN adduser --disabled-password --gecos '' appuser
USER appuser

# Copy installed packages from builder
COPY --from=builder /install /usr/local

# Copy application code
COPY --chown=appuser:appuser . .

EXPOSE 8080
ENTRYPOINT ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

Go with Distroless

Go compiles to a single static binary; use a distroless base image for production — it contains only the runtime (no shell, no package manager), dramatically reducing the attack surface and image size.

bashDockerfile.go
# Build stage: full Go toolchain
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0: statically link, no libc dependency
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /app ./cmd/server

# Production stage: distroless — no shell, just the binary
FROM gcr.io/distroless/static-debian12:nonroot AS production
# nonroot variant runs as UID 65532
COPY --from=builder /app /app
EXPOSE 8080
ENTRYPOINT ["/app"]

Layer Caching Best Practices

Order Dockerfile instructions from least-to-most frequently changing; Docker caches layers and invalidates them (and all subsequent layers) when content changes — wrong ordering kills build speed.

bashDockerfile.cache
# GOOD: copy dependency files first (changes rarely), then install, then copy code
COPY requirements.txt .           # layer 1: changes only when deps change
RUN pip install -r requirements.txt  # layer 2: re-runs only when layer 1 changes
COPY . .                          # layer 3: changes every commit (but layers 1+2 are cached)

# BAD: copying all code first invalidates the pip install layer on every code change
COPY . .                          # invalidated every commit
RUN pip install -r requirements.txt  # re-runs every build!

# BuildKit cache mounts (npm/pip/apt) — shares cache across builds
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

RUN --mount=type=cache,target=/var/cache/apt \
    apt-get update && apt-get install -y --no-install-recommends <package>

Security Best Practices

  • Run as non-root: create a user in the Dockerfile and switch to it with USER. Never run as UID 0.
  • Pin base images to digests in production: FROM python:3.12-slim@sha256:abc123 — tag mutation is a supply-chain risk.
  • Use .dockerignore: exclude .git, .env, *.key, test directories, and local build artifacts from the build context.
  • Combine RUN commands for apt-get to avoid leaving package lists in a separate layer: RUN apt-get update && apt-get install -y pkg && rm -rf /var/lib/apt/lists/*
  • !Never hardcode secrets in Dockerfiles or build args — they appear in docker history. Use build-time secrets with --mount=type=secret.
bash.dockerignore
.git
.gitignore
.env
*.key
*.pem
*.crt
__pycache__
*.pyc
.venv
node_modules
tests/
docs/
*.md
Dockerfile*
docker-compose*.yaml