Best Practices
The definitive checklist for production-grade Dockerfiles - security, caching, size, linting, and the habits that separate good images from great ones.
Best Practices
A Dockerfile that works is not the same as a Dockerfile that's production-ready. These practices address security, performance, maintainability, and reliability - the four axes of a well-crafted image.
Security
1. Never run as root
# Default - root in production
FROM node:20-alpine
CMD ["node", "server.js"]
# Non-root user
FROM node:20-alpine
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]
If an attacker exploits your app inside a root container, they have root on the host. A non-root user contains the blast radius.
2. Use read-only filesystem where possible
docker run --read-only myapp
Or mark specific write paths in the Dockerfile:
VOLUME /tmp
VOLUME /app/uploads
# Everything else is read-only at runtime with --read-only
3. Never bake secrets into images
# Secret in ENV - visible in docker inspect and image history
ENV DB_PASSWORD=supersecret
# Secret in ARG - visible in docker history
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
# BuildKit secret mount - never stored in any layer
RUN --mount=type=secret,id=npm_token \
echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" >> .npmrc && \
npm install && \
rm .npmrc
4. Pin base image versions
# Unpredictable
FROM node:latest
# Specific version - reproducible
FROM node:20.11.0-alpine3.19
# Most secure - digest pinning
FROM node:20-alpine@sha256:bf77dc26e48ea95fca9d1aceb5acfa69d2e546b765ec2abfb502975f1a2d4def
5. Scan images regularly
# Docker Scout (built-in)
docker scout cves myapp:latest
# Trivy (open source)
trivy image myapp:latest
# Include in CI pipeline
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest
Build Performance (Caching)
6. Order by change frequency - least to most
FROM node:20-alpine # Never changes
WORKDIR /app # Rarely changes
RUN apk add --no-cache curl # Rarely changes
COPY package*.json ./ # Changes when deps update
RUN npm ci # Cached unless package.json changes
COPY . . # Changes every commit
CMD ["node", "server.js"]
7. Separate dependency install from source copy
This is the single highest-impact caching optimisation for most projects:
# npm ci only reruns when package.json changes - not on every code edit
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
8. Use .dockerignore
node_modules/
.git/
.env
*.log
coverage/
dist/
.DS_Store
Without it, COPY . . sends gigabytes to the daemon and busts the cache when irrelevant files change.
Image Size
9. Use the smallest viable base image
# Pick the right variant for your needs
FROM python:3.12-alpine # ~50MB base
FROM python:3.12-slim # ~120MB base - use if Alpine causes issues
FROM python:3.12 # ~1GB base - almost never needed
10. Combine RUN commands and clean up in the same layer
# Apt cache stays in Layer 1 even though Layer 2 deletes it
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Truly clean - added and removed in the same layer
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
11. Use multi-stage builds
# Build stage - has compiler, dev deps
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
# Production stage - minimal
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
RUN npm ci --omit=dev
CMD ["node", "dist/server.js"]
For compiled languages (Go, Rust), the savings are even more dramatic - a 300MB build stage becomes a 15MB runtime image.
Reliability
12. Use exec form for CMD and ENTRYPOINT
# Shell form - SIGTERM not forwarded to app
CMD node server.js
# Exec form - app is PID 1, receives signals directly
CMD ["node", "server.js"]
13. Add a HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
Orchestrators use health checks to detect failures and restart unhealthy containers before users notice.
14. Set a non-zero STOPSIGNAL if needed
# Tell Docker to use SIGQUIT for graceful nginx shutdown
STOPSIGNAL SIGQUIT
Some applications expect a specific signal for graceful shutdown. Check your runtime's documentation.
Maintainability
15. Add OCI labels
LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.version="2.1.0"
LABEL org.opencontainers.image.authors="team@example.com"
16. Lint your Dockerfiles
Hadolint is the standard Dockerfile linter. It catches common mistakes and enforces best practices:
# Install hadolint
brew install hadolint # macOS
# or: docker run --rm -i hadolint/hadolint < Dockerfile
# Lint your Dockerfile
hadolint Dockerfile
Common hadolint warnings:
DL3008- Pin versions in apt-get installDL3009- Delete the apt-get lists after installingDL3013- Pin pip packagesSC2086- Double-quote variables to avoid word-splitting
Integrate hadolint into your CI pipeline to catch issues before they reach production.
The Production Dockerfile Checklist
Before merging any Dockerfile change, verify:
- Base image uses a specific version tag (not
latest) -
.dockerignoreexists and excludesnode_modules,.env,.git - Dependency files copied and installed before source code
-
RUNcleanup happens in the same layer as the install - No secrets in
ENVorARG -
USERswitches to non-root beforeCMD -
CMDuses exec form["..."] -
EXPOSEdocuments the listening port -
HEALTHCHECKis defined - Multi-stage build used if there's a compile/build step
-
hadolintpasses with no errors
Lint a Dockerfile
You want to run hadolint against the Dockerfile in the current directory using the official hadolint Docker image, without leaving behind a stopped container afterward. Which command is correct?
Summary
The most impactful practices, ranked:
- Non-root USER - biggest security win for one line of code
- Exec form CMD - critical for graceful shutdown
- Layer cache order - biggest build speed win
.dockerignore- prevents accidental secret leaks and speeds up builds- Multi-stage builds - biggest image size win for compiled apps
- HEALTHCHECK - automatic failure detection
- Version pinning - reproducible builds
- hadolint in CI - catches the rest before it ships