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 install
  • DL3009 - Delete the apt-get lists after installing
  • DL3013 - Pin pip packages
  • SC2086 - 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)
  • .dockerignore exists and excludes node_modules, .env, .git
  • Dependency files copied and installed before source code
  • RUN cleanup happens in the same layer as the install
  • No secrets in ENV or ARG
  • USER switches to non-root before CMD
  • CMD uses exec form ["..."]
  • EXPOSE documents the listening port
  • HEALTHCHECK is defined
  • Multi-stage build used if there's a compile/build step
  • hadolint passes with no errors
Exercise

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:

  1. Non-root USER - biggest security win for one line of code
  2. Exec form CMD - critical for graceful shutdown
  3. Layer cache order - biggest build speed win
  4. .dockerignore - prevents accidental secret leaks and speeds up builds
  5. Multi-stage builds - biggest image size win for compiled apps
  6. HEALTHCHECK - automatic failure detection
  7. Version pinning - reproducible builds
  8. hadolint in CI - catches the rest before it ships

Continue Learning

Explore Related Topics

Try the Tool

Related Resources