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 appuserFor 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: truePin 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.6Minimize 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__
*.pycWithout 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.gzNever 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/configInstead, 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 installdocker 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 DockerfileIt 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)
USERdirective set to non-root- All image tags and package versions pinned
.dockerignoreexcludes.git,.env,node_modules- No secrets in build args – use BuildKit secret mounts
- Layers combined where possible, apt cache cleaned in the same
RUN COPYused instead ofADD- hadolint running in CI