Dockerfile Best Practices #
Docker images are the foundation of the entire container ecosystem. A bad image directly impacts security, size, build speed, startup time, and production stability. Conversely, a well-designed Docker image makes deployments faster, scaling more efficient, and operations calmer.
This article covers universal Dockerfile best practices, not tied to any specific programming language. Every language (Go, Java, Python, Rust, Node.js, Ruby, PHP) already has its own article in this section — here we focus on the cross-language principles that form the foundation of every good Dockerfile.
1. Always Use Multi-Stage Builds #
Multi-stage builds are the mandatory foundation of modern Dockerfiles. They separate the build environment (with compilers, package managers, and tools) from the runtime environment (only the final artifacts and minimum dependencies).
Why it’s mandatory:
- Smaller runtime images — compilers and build tools don’t come along.
- Narrower attack surface — no exploitable compilers.
- Clear build boundaries — the difference between “needed for build” and “needed for runtime” is explicit in the Dockerfile.
- Safer supply chain — fewer dependencies to patch.
// ANTI-PATTERN: everything in one stage, fat image
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o app
CMD ["./app"]
// ~300 MB image because it carries compilers and tools
// CORRECT: multi-stage, slim image
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o app
FROM gcr.io/distroless/base-debian12
COPY --from=builder /app/app /app
CMD ["/app"]
// ~15 MB image, binary only
A practical rule: if your Dockerfile only has one FROM, it can almost certainly be improved. Multi-stage isn’t an optimization — it’s the standard.
2. Choose a Base Image That Fits Your Needs #
The base image determines your image’s foundation. Choose the one that best fits your runtime needs, not the one you’re most familiar with.
| Base Image | Use Case | Size |
|---|---|---|
scratch | Static Go/Rust binaries without HTTPS | 0 MB |
gcr.io/distroless/* | Production Java, Node.js, Python, Go | 10-50 MB |
alpine | Languages needing runtime tools, glibc/musl compatible | 5-10 MB |
-slim variants | The safe default, shell for debugging | 50-150 MB |
| default tags | Development, prototyping | 200-900 MB |
ubuntu / debian | Full OS tools needed, interactive debugging | 70-100 MB |
Selection principles:
- High-maturity production → distroless (observability must be solid).
- Default production → alpine or slim (debugging capability).
- Development → default tags (many tools, easy debugging).
- Static binaries → scratch.
// ✗ ANTI-PATTERN: default base image for production
FROM node:20
// 900+ MB image
// ✓ CORRECT: slim base image for production
FROM node:20-alpine
// 50-100 MB image after optimization
Explicit tags, not latest:
// ✗ Non-deterministic
FROM node:latest
// ✓ Reproducible
FROM node:20.11.1-alpine3.20
3. Don’t Run Containers as Root #
The default is for containers to run as root (UID 0). That’s dangerous. Always create a non-root user with an explicit UID.
Why it matters:
- Reduces the impact of container escapes — attackers don’t immediately get host root.
- Compliance — many security standards (PCI-DSS, SOC2) require non-root.
- Least privilege — containers only have the permissions needed.
- Safer volume mounts — files are mounted with the user’s permissions, not root’s.
// ✗ Container runs as root
FROM alpine
COPY app /app
CMD ["/app"]
// ✓ Container runs as non-root
FROM alpine
RUN addgroup -g 1001 -S appgroup \
&& adduser -u 1001 -S appuser -G appgroup
COPY --chown=appuser:appgroup app /app
USER appuser
CMD ["/app"]
For distroless — the nonroot user is built in:
FROM gcr.io/distroless/base-debian12:nonroot
USER nonroot:nonroot
For scratch — you must set up /etc/passwd manually or accept the default.
4. Separate Production and Development Dependencies #
Almost every language has a way to separate dependencies:
- Node.js:
npm ci --omit=devor separatedependenciesvsdevDependenciesinpackage.json. - Python: separate
requirements.txt(prod) andrequirements-dev.txt. - Ruby:
bundle config set without 'development test'. - PHP:
composer install --no-dev. - Go:
go mod tidyonly pulls imported dependencies. - Rust: separate
[dependencies]vs[dev-dependencies]inCargo.toml. - Java/Maven:
<scope>provided</scope>or<scope>test</scope>inpom.xml.
// ✗ All dependencies including dev
RUN npm install
// node_modules now has typescript, jest, eslint, etc.
// ✓ Production dependencies only
RUN npm ci --omit=dev
// or separate
COPY package.json ./
RUN npm ci --omit=dev && npm cache clean --force
The principle: production images must not carry test frameworks, debuggers, formatters, or linters. None of those are needed at runtime.
5. Use .dockerignore Firmly
#
.dockerignore is a frequently underestimated weapon. Without it, the build context can be hundreds of MB (including node_modules, .git, .env, etc.) and the build process will be slow.
Must be excluded:
# .dockerignore
.git
.gitignore
.env
.env.*
node_modules
vendor
target
dist
build
__pycache__
*.pyc
*.log
coverage
.DS_Store
.vscode
.idea
*.md
docs/
tests/
The impact of a good .dockerignore:
- Smaller build context → faster
docker build. - No sensitive files (
.env, credentials) end up in the image. - No local dependencies conflicting with Docker dependencies.
flowchart LR
A[500 MB Build Context] -->|Without .dockerignore| B[Slow Build + Risk]
C[10 MB Build Context] -->|With .dockerignore| D[Fast Build + Safe]6. Don’t Store Secrets in Docker Images #
Secrets in an image will be exposed forever. Anyone with access to the image (or the registry) can extract them. This is the most common security mistake.
// ✗ FATAL: secret baked into the image
FROM node:20
ENV DB_PASSWORD=supersecret
COPY credentials.json /app/
// ✓ Secret injected at runtime
FROM node:20
# No secrets in the Dockerfile
How to inject secrets at runtime:
- Environment variables:
docker run -e DB_PASSWORD=xxx - File mounts:
docker run -v /host/secret:/app/secret:ro - Docker secrets (Swarm mode)
- Kubernetes secrets
- External secret managers: AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager
Build-time secrets (build only, not stored in the image):
# syntax=docker/dockerfile:1.7
FROM node:20
# Use a BuildKit secret mount
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
The principle: Docker images are public artifacts — they can be pushed to registries, shared, and cached. Treat images like code published to npm. No credentials may be inside them.
7. Use a Proper Image Tagging Strategy #
latest is an anti-pattern for production. Tags must be deterministic and rollback-able.
Avoid latest:
# ✗ Non-deterministic
docker build -t myapp:latest .
# ✗ Unclear version
docker build -t myapp:1 .
A good tagging strategy:
# Semantic versioning
docker build -t myapp:1.4.0 .
# Git commit hash (immutable)
docker build -t myapp:git-a1b2c3d .
# Build number (CI)
docker build -t myapp:1.4.0-$BUILD_NUMBER .
# Combination
docker build -t myapp:1.4.0-a1b2c3d .
Principles:
- Explicit tags are better than
latest. - Git hashes for full reproducibility.
- Semantic versions for releases known to be stable.
- Build numbers for deployment tracking.
8. Optimize Layers for Build Caching #
Dockerfile instruction order determines build performance. Rarely-changing things on top, frequently-changing things at the bottom.
// ✗ Bad order: cache often invalidated
FROM node:20-alpine
WORKDIR /app
COPY . . # source code on top
RUN npm install # dependency install below
CMD ["npm", "start"]
// Every code change → npm install re-runs
// ✓ Correct order: optimal caching
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./ # dependency definitions first
RUN npm install # install once, cached
COPY . . # source code last
CMD ["npm", "start"]
// Code changes → npm install not repeated
Layering principles:
- Base image at the very top (rarely changes).
- OS dependencies after the base image (rarely change).
- App dependency definitions after OS deps (rarely change).
- App dependency install after the definitions (rarely changes).
- Source code at the bottom (often changes).
- ENTRYPOINT/CMD at the very bottom (rarely changes).
Extra: Combine related commands into one RUN to reduce the number of layers:
// ✗ Three layers, caches not cleaned
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
// ✓ One layer, everything clean
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
9. Log to STDOUT, Not Files #
Production containers must not write logs to files. Orchestrators (Kubernetes, ECS) already have mechanisms for collecting logs from STDOUT/STDERR.
The 12-factor apps principle:
- Logs are an event stream, not a file.
- Applications write to STDOUT/STDERR.
- The orchestrator/container runtime collects the logs.
- JSON structured format for production (easy for aggregators to parse).
# ✗ Logging to a file
import logging
logging.basicConfig(filename='/var/log/app.log')
# ✓ Logging to STDOUT
import logging
import sys
logging.basicConfig(stream=sys.stdout, format='%(asctime)s %(levelname)s %(message)s')
// ✓ Go: log to os.Stdout
log.SetOutput(os.Stdout)
// ✓ Node.js: console.log automatically goes to STDOUT
console.log({ event: 'request', path: '/api/users', status: 200 });
Benefits:
- Logs don’t disappear when containers restart.
- A consistent format aggregators can parse.
- No log rotation problems.
- Streamable to Elasticsearch, Loki, CloudWatch, etc.
10. Scan Images for Vulnerabilities Regularly #
A small image ≠ a secure image. Images built from outdated base images definitely have CVEs. Scanning must be part of CI/CD.
Popular tools:
- Trivy — open source, easy to use, supports many formats.
- Snyk — commercial, broad CVE database.
- Docker Scout — built into Docker Hub and Docker Desktop.
- Grype — open source from Anchore.
- Clair — open source from Red Hat.
CI/CD integration:
# GitHub Actions
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
run: trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:${{ github.sha }}
What gets scanned:
- OS packages — CVEs in
apt,apk,yumpackages. - Application dependencies — CVEs in
pip,npm,gem,composerpackages. - Base images — make sure the base image you chose is up to date.
The principle: an image isn’t just a build result — it’s a deployable artifact with its own security posture. It must be tracked, scanned, and patched like any other software.
Anti-Patterns to Avoid #
// ✗ Anti-pattern 1: default base image
FROM ubuntu:latest
// Large image, unnecessary for most cases
// ✓ Solution: slim, explicit base image
FROM ubuntu:24.04
// Or better: alpine, distroless, slim
// ✗ Anti-pattern 2: inefficient layers
RUN apt-get update
RUN apt-get install -y package
// Caches pile up, layers not optimal
// ✓ Solution: combine and remove caches
RUN apt-get update && apt-get install -y --no-install-recommends \
package \
&& rm -rf /var/lib/apt/lists/*
// ✗ Anti-pattern 3: secrets in images
ENV API_KEY=abc123
COPY credentials.json /app/
// Credentials leak into the image
// ✓ Solution: inject at runtime
# Dockerfile has no secrets
# docker run -e API_KEY=xxx myapp
# or use a secret manager
// ✗ Anti-pattern 4: containers as root
FROM alpine
COPY app /app
CMD ["/app"]
// Root by default, privilege escalation risk
// ✓ Solution: non-root user
FROM alpine
RUN adduser -D -u 1001 appuser
COPY --chown=appuser app /app
USER appuser
CMD ["/app"]
// ✗ Anti-pattern 5: copying the entire context
COPY . /app
// node_modules, .git, .env end up inside
// ✓ Solution: strict .dockerignore + explicit copies
COPY package.json package-lock.json ./
RUN npm ci
COPY src ./src
// ✗ Anti-pattern 6: shell form in CMD/ENTRYPOINT
CMD npm start
// sh becomes PID 1, signals never reach npm
// ✓ Solution: exec form
CMD ["npm", "start"]
// ✗ Anti-pattern 7: logging to files
CMD ["./app", "--log-file=/var/log/app.log"]
// Logs disappear when the container restarts
// ✓ Solution: log to STDOUT
CMD ["./app"]
// The application must log to STDOUT
// ✗ Anti-pattern 8: tagging without a strategy
docker build -t myapp .
// The image becomes "myapp:latest", can't roll back
// ✓ Solution: explicit tags
docker build -t myapp:1.4.0-a1b2c3d .
Universal Dockerfile Review Checklist #
FRONT MATTER & TAG:
□ Short, descriptive title
□ Weight in multiples of 10
□ Description 150-250 characters
□ BookToC: true
STRUCTURE:
□ H1 with the format # **Title** (bold)
□ No separator (---) before the first H2
□ Every H2 has an introductory sentence
□ A summary at the end with a tip hint box (summary mandatory)
CONTENT:
□ The opening paragraph doesn't start with "In this article"
□ Anti-patterns paired with CORRECT solutions
□ Code comments use the CORRECT / ANTI-PATTERN / ✓ / ✗ conventions
□ Use "you" consistently
CODE & DIAGRAMS:
□ Every code block has a language label
□ Diagrams use Mermaid (not ASCII)
□ Data tables use markdown tables
BASE IMAGE:
□ Explicit tag (image:version-variant, not latest)
□ Slim base image (alpine/slim/distroless, not default)
□ Runtime stage differs from the build stage (multi-stage)
BUILD:
□ Multi-stage build
□ COPY dependency files first, source code last
□ Build tools (compilers, *-dev) only in the build stage
□ Package manager caches removed (rm -rf /var/lib/apt/lists/* etc.)
□ pip install --no-cache-dir
□ npm ci (not npm install)
□ bundle install --without development test
□ composer install --no-dev
RUNTIME:
□ USER nonroot (UID >= 1000)
□ EXPOSE the port
□ ENTRYPOINT/CMD in exec form
□ Healthcheck (HEALTHCHECK or orchestrator probes)
□ Logs to STDOUT (not files)
□ Signal handling (SIGTERM handler in the application)
SIZE:
□ < 100 MB for distroless runtime
□ < 200 MB for alpine/slim runtime
□ docker history shows no oddly large layers
SECURITY:
□ No secrets in the image
□ Strict .dockerignore (.git, .env, node_modules, etc.)
□ Image scanned with trivy/grype in CI
□ Base image up to date (CVE patched)
□ Container isn't root
OPERATIONS:
□ Explicit image tags (semantic version or git hash)
□ Immutable images (not modified after build)
□ Observability: logs + metrics + healthcheck
□ Reproducible builds (lock files committed)
Summary #
- Multi-stage builds are the foundation — separate the build environment from the runtime environment. One stage = a red flag.
- The right base image: distroless for mature production, alpine/slim for the default, scratch for static binaries. Avoid default tags for production.
- Non-root users are mandatory — explicit UIDs, distroless already has
nonroot:nonroot. Root containers = privilege escalation risk.- Separate dev vs prod dependencies —
npm ci --omit=dev,composer install --no-dev,bundle config set without 'development test'. Production images must not carry test tools.- Strict
.dockerignore— exclude.git,.env,node_modules,vendor,target,dist, test files. A small build context = fast + safe builds.- No secrets in images — inject at runtime via env vars, file mounts, or secret managers. Docker images are public artifacts.
- Explicit tags, not
latest— semantic version + git hash. Reproducibility matters for auditing and rollbacks.- Layer ordering — rarely-changing things on top (base, OS deps, app dep definitions), frequently-changing at the bottom (source code). Maximize cache hits.
- Log to STDOUT — not files. Production containers must not write logs to local files. Use JSON structured format.
- Scan for vulnerabilities in CI/CD — Trivy, Grype, Snyk, Docker Scout. A small image ≠ a secure image.
- Exec form in CMD/ENTRYPOINT —
CMD ["app"], notCMD app. So signal handling works correctly.- Slim images need solid observability — JSON logs, metrics, healthchecks, graceful shutdown. A small image + solid observability beats a large image + manual debugging.
- Commit image tags and lock files — for build reproducibility. Auditing and rollbacks are only possible with reproducible builds.
- Best practices aren’t a checklist — they’re a mindset. Every Dockerfile is a decision about security, size, and operations. Choose deliberately.