Docker Best Practices: Multi-Stage Builds, Security Scanning, and Image Optimization

Your Dockerfile Is Probably Bigger Than It Needs to Be

I've reviewed hundreds of production Dockerfiles. The most common issue? Images that are 10-50x larger than necessary because nobody thought about what actually needs to be in the final image. A Python app that needs 50MB of code ships in a 1.2GB image because it includes gcc, build headers, pip cache, and the entire Debian base.

Let's fix that.

Multi-Stage Builds

Multi-stage builds are the single most impactful Docker optimization. You use one stage to build your application and another — smaller — stage to run it. Build dependencies stay in the build stage and never make it into the final image.

# Stage 1: Build
FROM python:3.12-slim AS builder

WORKDIR /app
RUN pip install --no-cache-dir poetry==1.8.2

COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt -o requirements.txt     && pip install --no-cache-dir --prefix=/install -r requirements.txt

COPY src/ ./src/

# Stage 2: Runtime
FROM python:3.12-slim

WORKDIR /app
COPY --from=builder /install /usr/local
COPY --from=builder /app/src ./src

# Non-root user
RUN useradd -r -s /bin/false appuser
USER appuser

EXPOSE 8000
CMD ["gunicorn", "src.main:app", "-b", "0.0.0.0:8000", "-w", "4"]

The builder stage has Poetry and all build tools. The runtime stage has only the Python runtime and installed packages. Typical size reduction: 800MB → 150MB.

For Go applications, the savings are even more dramatic because Go produces static binaries:

# Go multi-stage: 1.2GB -> 12MB
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server

FROM scratch
COPY --from=builder /server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]

Building from scratch (an empty image) means your final image contains literally nothing except your binary and CA certificates. No shell, no package manager, no OS. Attack surface: basically zero.

Layer Caching Strategy

Docker caches each layer. When a layer's inputs change, that layer and all subsequent layers are rebuilt. Order your Dockerfile from least-frequently changing to most-frequently changing.

# Bad: code change invalidates dependency install
COPY . .
RUN pip install -r requirements.txt

# Good: dependencies cached until requirements.txt changes
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

This is Docker 101, but I still see the "bad" pattern regularly. For a project with 200MB of dependencies, this saves 2-5 minutes on every build where only application code changed.

.dockerignore

A missing or incomplete .dockerignore means your build context includes everything — git history, node_modules, virtual environments, IDE files. This slows down the build context transfer and can leak sensitive files into the image.

# .dockerignore
.git
.env
*.pyc
__pycache__
node_modules
.venv
*.md
tests/
docs/
.github/
docker-compose*.yml

Base Image Selection

Your base image choice has a bigger impact than most optimizations you'll make in the Dockerfile itself.

  • Alpine — (~5MB) — Smallest. Uses musl libc instead of glibc, which can cause issues with some Python packages that compile C extensions. I've been burned by numpy and pandas segfaulting on Alpine. Use it for Go/Rust static binaries or Node.js apps.
  • Slim variants — (~80MB) — python:3.12-slim, node:20-slim. These strip documentation and uncommon tools from the full image. Best default choice for most applications.
  • Distroless — (~20MB) — Google's distroless images contain only the language runtime. No shell, no package manager. Smaller attack surface than slim, but you can't docker exec into the container for debugging.
  • Full images — (~900MB) — python:3.12, node:20. Only use these as build stages, never as runtime bases.

Security Scanning

Your image inherits every vulnerability in its base image and installed packages. A typical python:3.12-slim image has 30-50 known CVEs at any given time, most of which are in the OS layer and don't affect your application. But some do.

Scanning Tools

Trivy (Aqua Security) is my recommendation for most teams. It's open-source, fast, and scans both OS packages and application dependencies. Run it in CI:

# In your CI pipeline
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest

Docker Scout is built into Docker Desktop and the Docker CLI. docker scout cves myapp:latest gives you a vulnerability report. It's convenient but less configurable than Trivy.

Snyk Container integrates with your CI/CD and provides fix recommendations. It'll suggest base image upgrades that resolve specific CVEs.

Keeping Images Updated

Pin your base image to a specific version (python:3.12.3-slim) for reproducible builds, but set up automated PR creation (Dependabot, Renovate) to bump the version when security patches are released. Don't use :latest — you'll get different images on different builds, which breaks reproducibility and makes debugging impossible.

Runtime Configuration

Non-Root User

Running as root inside a container is a bad habit. If an attacker escapes the container, they're root on the host (in many configurations). Always create and switch to a non-root user:

RUN groupadd -r appgroup && useradd -r -g appgroup -s /bin/false appuser
USER appuser

Health Checks

Define a health check so Docker (and orchestrators like Kubernetes) know when your container is actually ready to serve traffic:

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3     CMD curl -f http://localhost:8000/health || exit 1

Signal Handling

Use exec form for CMD and ENTRYPOINT so your application receives signals directly (SIGTERM for graceful shutdown). Shell form wraps your command in /bin/sh -c, which doesn't forward signals.

# Good: exec form, app receives SIGTERM directly
CMD ["gunicorn", "app:main", "-b", "0.0.0.0:8000"]

# Bad: shell form, sh receives SIGTERM, app might not
CMD gunicorn app:main -b 0.0.0.0:8000

For applications that need cleanup on shutdown (flushing buffers, closing database connections, finishing in-progress requests), handle SIGTERM in your application code. Kubernetes sends SIGTERM and waits 30 seconds before sending SIGKILL. If your app doesn't handle SIGTERM, it gets killed without cleanup.