Dockerfile Best Practices#

A Dockerfile is a security boundary. Every decision – base image, installed package, file copied in, user the process runs as – determines the attack surface of your running container. Most Dockerfiles in the wild are bloated, run as root, and ship debug tools an attacker can use. Here is how to fix that.

Choose the Right Base Image#

Your base image choice is the single biggest factor in image size and vulnerability count.

Base Image Size (approx.) Packages Use Case
ubuntu:24.04 78 MB Full apt ecosystem When you need apt and a shell
python:3.12-slim 52 MB Minimal Debian + Python Python apps needing some OS libs
alpine:3.20 7 MB musl libc, apk Small images, but musl can cause subtle bugs
gcr.io/distroless/static 2 MB Nothing – no shell, no package manager Go/Rust static binaries
gcr.io/distroless/cc 5 MB glibc only C/C++ apps needing glibc

Alpine uses musl libc instead of glibc, which can cause DNS resolution issues, performance differences in memory allocation, and crashes with some C extensions. Test thoroughly before committing to Alpine for anything beyond trivial workloads.

Distroless images have no shell and no package manager. An attacker who gets code execution inside the container cannot easily install tools or explore. This is the strongest default for compiled languages.

Multi-Stage Builds#

The most impactful optimization. Build in one stage, copy only the artifact to a minimal runtime stage.

Before – single stage, 1.2 GB:

FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["/app/server"]

This ships the entire Go toolchain, source code, and module cache.

After – multi-stage, 8 MB:

# Build stage
FROM golang:1.23 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .

# Runtime stage
FROM gcr.io/distroless/static:nonroot
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]

The runtime image contains only the static binary. No compiler, no source code, no shell.

For Python, the pattern uses a builder for pip installs:

FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.12-slim
COPY --from=build /install /usr/local
WORKDIR /app
COPY . .
USER 1000
CMD ["python", "main.py"]

Run as Non-Root#

By default, containers run as root. If your application gets compromised, the attacker has root inside the container. Combined with a kernel vulnerability, that can mean root on the host.

# Create a non-root user
RUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 --ingroup appgroup appuser

# Set ownership on the app directory
COPY --chown=appuser:appgroup . /app

USER appuser

For distroless, use the built-in nonroot tag or set USER 65534:

FROM gcr.io/distroless/static:nonroot
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]

Kubernetes can enforce this at the pod level too, but the Dockerfile should not depend on that:

securityContext:
  runAsNonRoot: true
  runAsUser: 1001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true

Pin Versions Everywhere#

Never use :latest in production. It is not reproducible and breaks cache invalidation.

# Bad -- what version is this next month?
FROM python:latest

# Better -- pinned minor version
FROM python:3.12-slim

# Best -- pinned to digest for reproducibility
FROM python:3.12-slim@sha256:abcdef1234567890...

Pin package versions too:

# Bad
RUN apt-get install -y curl

# Good
RUN apt-get install -y curl=8.5.0-2ubuntu10.6

Minimize Layers and Use .dockerignore#

Each RUN, COPY, and ADD instruction creates a layer. Combine related commands:

# Bad -- 3 layers, apt cache persisted in first layer
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Good -- 1 layer, cache cleaned in same layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl=8.5.0-2ubuntu10.6 && \
    rm -rf /var/lib/apt/lists/*

A .dockerignore prevents unnecessary files from entering the build context:

.git
.env
node_modules
*.md
docker-compose*.yml
.github
__pycache__
*.pyc

Without this, COPY . . sends everything to the Docker daemon, including your .git directory (often hundreds of megabytes) and potentially your .env file with secrets.

COPY vs ADD#

Use COPY. Always. ADD has two extra behaviors: it auto-extracts tar archives and can fetch URLs. Both are implicit and surprising. If you need to extract a tar file, be explicit:

# Bad -- implicit extraction, unclear intent
ADD app.tar.gz /app

# Good -- explicit about what is happening
COPY app.tar.gz /tmp/app.tar.gz
RUN tar -xzf /tmp/app.tar.gz -C /app && rm /tmp/app.tar.gz

Never Put Secrets in Build Args#

Build args are visible in the image history. Anyone with docker history can see them.

# NEVER do this
ARG DB_PASSWORD
RUN echo "password=$DB_PASSWORD" > /etc/app/config

Instead, inject secrets at runtime via environment variables or mounted volumes. If you need secrets during the build (e.g., for pulling private packages), use BuildKit secret mounts:

# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm install
docker build --secret id=npmrc,src=$HOME/.npmrc .

The secret is available during the build step but is never written to a layer.

Add a HEALTHCHECK#

The HEALTHCHECK instruction tells Docker (and Kubernetes, if not overridden by probes) how to verify the container is working:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD ["/server", "--health-check"]

For Kubernetes deployments, you will typically use livenessProbe and readinessProbe in the pod spec instead, but HEALTHCHECK is valuable for Docker Compose and standalone Docker usage.

Lint Your Dockerfiles#

Use hadolint to catch common mistakes:

hadolint Dockerfile

It flags issues like missing version pins, use of ADD instead of COPY, running as root, and apt-get without --no-install-recommends. Run it in CI as a gate.

Quick Checklist#

  • Multi-stage build separating build and runtime stages
  • Minimal base image (distroless for compiled languages, slim variants for interpreted)
  • USER directive set to non-root
  • All image tags and package versions pinned
  • .dockerignore excludes .git, .env, node_modules
  • No secrets in build args – use BuildKit secret mounts
  • Layers combined where possible, apt cache cleaned in the same RUN
  • COPY used instead of ADD
  • hadolint running in CI