Dockerfile Best Practices
Writing efficient Dockerfiles is both an art and a science. This guide covers proven best practices for creating smaller, faster, more secure Docker images using multi-stage builds, layer caching optimization, and security patterns used by industry leaders.
A well-written Dockerfile produces images that are smaller, build faster, are more secure, and easier to maintain. Poorly written Dockerfiles can result in images that are gigabytes in size, have security vulnerabilities, and take minutes to build. The practices in this guide will help you create production-ready images that follow industry standards.
Docker caches each layer. If a layer hasn't changed, Docker reuses the cached version. To maximize cache hits, order your Dockerfile instructions from least frequently changed to most frequently changed. Put base images, environment variables, and dependency installation FIRST. Put your application code LAST.
This means: FROM → WORKDIR → ENV → COPY package.json → RUN npm install → COPY source code → CMD. This way, when you change only your source code, Docker reuses the cached layers for dependencies, saving significant build time.
# Bad: Reinstalls dependencies on every code change
FROM node:18-alpine
COPY . /app
WORKDIR /app
RUN npm install
CMD ["npm", "start"]
# Good: Dependencies cached unless package.json changes
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["npm", "start"]
Multi-stage builds allow you to use multiple FROM statements in a single Dockerfile. Each stage can have its own base image and instructions. You can copy artifacts from earlier stages into the final stage, leaving behind build tools, compilers, and intermediate files that aren't needed at runtime.
This is especially powerful for compiled languages (Go, Rust, Java, C++) and for frontend applications that need build tools like webpack. The final image contains only the runtime dependencies and compiled artifacts, dramatically reducing image size.
# Multi-stage build for Go application
# Stage 1: Build (includes compilers)
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp .
# Stage 2: Runtime (no compilers, much smaller)
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/myapp .
EXPOSE 8080
CMD ["./myapp"]
Each RUN, COPY, and ADD instruction creates a new layer. While layers are not inherently bad, unnecessary layers increase image size and build complexity. Combine related commands into a single RUN using && and clean up temporary files in the same layer where they're created.
For apt-get, always combine update and install in the same RUN, and remove the package cache afterward. This prevents outdated cache from being frozen into the image layer.
# Bad: Multiple layers, cache persists
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN rm -rf /var/lib/apt/lists/*
# Good: Single layer with cleanup
RUN apt-get update && \
apt-get install -y curl git && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# For npm: combine install and cache cleanup
RUN npm ci --only=production && \
npm cache clean --force
Base images have dramatically different sizes. Ubuntu: ~70MB, Debian-slim: ~40MB, Alpine: ~5MB, Distroless: ~2MB. The smaller the base image, the smaller your final image and the smaller the attack surface. However, smaller images may have fewer debugging tools.
For production, use Alpine or Distroless. For development or debugging, use the full images. Always pin to specific version tags, never latest. This ensures reproducibility and prevents unexpected changes.
alpine:latestnode:18-alpinepython:3.11-slimgolang:alpinedistroless/static# Recommended: Specific Alpine variant
FROM node:18.17.0-alpine3.18
# Also recommended: Slim variants
FROM python:3.11-slim-bookworm
# Distroless (no shell - highest security)
FROM gcr.io/distroless/static-debian11
By default, containers run as root. This is a security risk—if an attacker compromises your container, they have root access. Create and switch to a non-root user before the CMD instruction. Use a specific UID (like 1000) rather than a username for better compatibility with volume mounts.
Also avoid storing secrets in images. Never hardcode passwords, API keys, or tokens. Use Docker secrets, environment variables, or external secret stores instead. Scan your images for vulnerabilities using Docker Scout or Trivy.
# Create non-root user
FROM node:18-alpine
# Add non-root user (alpine syntax)
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app
COPY --chown=nodejs:nodejs . .
# Switch to non-root user
USER nodejs
CMD ["node", "server.js"]
The .dockerignore file works like .gitignore. It prevents unnecessary files from being sent to the Docker daemon during docker build. This speeds up builds and keeps your image smaller by excluding files like node_modules, .git, logs, temporary files, and secrets.
# .dockerignore file example
node_modules/
npm-debug.log
.git/
.gitignore
.env
*.md
Dockerfile
.dockerignore
.idea/
.vscode/
coverage/
.nyc_output/
dist/ (if you rebuild in Docker)
HEALTHCHECK tells Docker how to test if a container is still working properly. Without it, Docker only knows if the process crashed. With HEALTHCHECK, orchestration tools like Docker Swarm and Kubernetes can automatically restart unhealthy containers.
# HEALTHCHECK examples
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost/ || exit 1
# For Node.js applications
HEALTHCHECK --interval=30s --timeout=3s \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# For databases
HEALTHCHECK --interval=30s --timeout=5s --retries=5 \
CMD pg_isready -U postgres || exit 1
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /build
# Copy dependency files first (for caching)
COPY package*.json ./
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build
RUN npm prune --production
# Stage 2: Production
FROM node:18-alpine
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app
# Copy built artifacts
COPY --from=builder --chown=nodejs:nodejs /build/package*.json ./
COPY --from=builder --chown=nodejs:nodejs /build/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /build/dist ./dist
# Environment
ENV NODE_ENV=production
ENV PORT=3000
# Security
USER nodejs
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start
CMD ["node", "dist/server.js"]
- Using
:latesttag - Breaks reproducibility. Always pin to specific versions. - Running as root - Security risk. Use USER instruction to switch to non-root.
- Copying entire directory before installing dependencies - Breaks layer caching. Copy dependency manifests first.
- Multiple RUN statements that could be combined - Creates unnecessary layers and size.
- Leaving temporary files and package caches - Clean up in the same layer.
- No HEALTHCHECK - Orchestration systems can't detect hanging containers.
- Storing secrets in images - Use Docker secrets or environment variables.
- Not using .dockerignore - Sends unnecessary files to daemon, slowing builds.
docker build --progress=plain to see detailed output showing which layers are cached. Lines with "CACHED" indicate cache hits. This helps you debug cache misses.RUN --mount=type=secret,id=mysecret. Never use COPY or ADD for secrets. Use environment variables at runtime (not build time). Use external secret stores like HashiCorp Vault or cloud secret managers.Following these best practices transforms your Dockerfiles from functional to production-ready. Smaller images deploy faster, are more secure, and save infrastructure costs.