Dockerfile Best Practices
Dockerfile Best Practices
Creating efficient, secure, and maintainable Docker images requires following best practices. This chapter covers multi-stage builds, layer optimization, security hardening, and patterns that production-ready images should follow.
Multi-Stage Builds
Multi-stage builds allow you to use multiple FROM statements in a single Dockerfile. This is essential for creating small, production-ready images.
The Problem
Build tools and dependencies bloat your final image:
# Without multi-stage: Large image with build tools
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
# Image contains: Node, npm, devDependencies, source code, build output
# Size: ~800MBThe Solution
Use separate stages for building and running:
# Stage 1: Build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
# Final image: Only runtime and build output
# Size: ~150MBMulti-Stage Size Comparison
| Approach | Image Size | Contents |
|---|---|---|
| Single stage | ~800MB | Node, npm, all deps, source, build |
| Multi-stage | ~150MB | Node, production deps, build output |
| Multi-stage (optimized) | ~50MB | Alpine Node, minimal deps |
Copying from Named Stages
# Name your stages
FROM node:20 AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM dependencies AS builder
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
# Copy from specific stages
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]Copying from External Images
# Copy from another image entirely
FROM alpine:3.19
# Copy binary from official image
COPY --from=golang:1.22 /usr/local/go/bin/go /usr/local/bin/
# Copy from a specific image version
COPY --from=nginx:1.25 /etc/nginx/nginx.conf /etc/nginx/Note
You can copy from any image, not just previous stages. This is useful for grabbing specific binaries or configurations.
Layer Optimization
Every Dockerfile instruction creates a layer. Optimizing layers improves build speed and image size.
Combine RUN Commands
# BAD: Multiple layers
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
RUN apt-get update && \
apt-get install -y \
curl \
git && \
rm -rf /var/lib/apt/lists/*Clean Up in the Same Layer
# BAD: Cleanup in separate layer doesn't reduce size
RUN apt-get update && apt-get install -y build-essential
RUN pip install -r requirements.txt
RUN apt-get purge -y build-essential # Still in previous layer!
# GOOD: Build and cleanup in same layer
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential && \
pip install -r requirements.txt && \
apt-get purge -y build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*Warning
Once a file is added to a layer, removing it in a later layer doesn't reduce the image size. The file still exists in the earlier layer.
Order for Maximum Cache Hits
# Order from least to most frequently changed:
# 1. Base image (rarely changes)
FROM node:20-alpine
# 2. System dependencies (occasionally change)
RUN apk add --no-cache python3 make g++
# 3. Dependency files (change when deps updated)
COPY package*.json ./
# 4. Install dependencies (cached if package.json unchanged)
RUN npm ci
# 5. Application code (changes frequently)
COPY . .
# 6. Build step
RUN npm run build
# 7. Runtime configuration (rarely changes)
CMD ["npm", "start"]Minimizing Image Size
Choose Minimal Base Images
# Full Debian (~900MB)
FROM python:3.12
# Slim Debian (~150MB)
FROM python:3.12-slim
# Alpine (~50MB)
FROM python:3.12-alpine
# Distroless (~20MB) - No shell, minimal attack surface
FROM gcr.io/distroless/python3Base Image Comparison
| Base Image | Size | Shell | Package Manager | Best For |
|---|---|---|---|---|
ubuntu:22.04 | 77MB | Yes | apt | Development |
debian:bookworm-slim | 74MB | Yes | apt | General use |
alpine:3.19 | 7MB | Yes | apk | Production |
distroless | 2-20MB | No | None | Secure production |
scratch | 0MB | No | None | Static binaries |
Use .dockerignore Effectively
# .dockerignore - Exclude everything not needed for build
# Version control
.git
.gitignore
.gitattributes
# Dependencies (reinstalled in build)
node_modules
vendor
__pycache__
*.pyc
# IDE and editor
.vscode
.idea
*.swp
*.swo
# Build outputs
dist
build
coverage
.nyc_output
# Documentation
*.md
!README.md
docs
# Docker files (not needed in context)
Dockerfile*
docker-compose*
.dockerignore
# Environment files (security!)
.env
.env.*
*.pem
*.key
# Test files
test
tests
__tests__
*.test.js
*.spec.jsRemove Unnecessary Files
# Remove caches and temporary files
RUN pip install -r requirements.txt && \
rm -rf ~/.cache/pip
RUN npm ci && \
npm cache clean --force
RUN apt-get update && \
apt-get install -y package && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Remove development dependencies after build
RUN npm ci && \
npm run build && \
npm prune --productionSecurity Best Practices
Run as Non-Root
FROM node:20-alpine
WORKDIR /app
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Copy files with proper ownership
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuser
CMD ["node", "server.js"]Warning
Running containers as root is a security risk. If an attacker escapes the container, they have root access to the host. Always use a non-root user in production.
Avoid Storing Secrets
# BAD: Secrets visible in image history
RUN echo "password123" > /app/config
ENV API_KEY=secret123
ARG DATABASE_PASSWORD
# GOOD: Use secrets at runtime
ENV DATABASE_URL= # Set at runtime
# docker run -e DATABASE_URL=postgres://... myappUse BuildKit Secrets
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
# Mount secret during build only
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
COPY . .
CMD ["npm", "start"]# Build with secret
docker build --secret id=npmrc,src=$HOME/.npmrc .Scan for Vulnerabilities
# Scan image for vulnerabilities
docker scout cves myapp:1.0
# Scan during build
docker build --sbom=true --provenance=true -t myapp:1.0 .
# Use third-party scanners
trivy image myapp:1.0
snyk container test myapp:1.0Use Read-Only Filesystem
# Set filesystem to read-only where possible
FROM node:20-alpine
WORKDIR /app
COPY . .
# Create writable directories for runtime data
RUN mkdir -p /app/tmp /app/logs && \
chown -R node:node /app/tmp /app/logs
USER node
CMD ["node", "server.js"]# Run with read-only filesystem
docker run --read-only --tmpfs /app/tmp --tmpfs /app/logs myapp:1.0Production Patterns
Health Checks
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --only=production
# Add health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node healthcheck.js || exit 1
CMD ["node", "server.js"]// healthcheck.js
const http = require("http");
const options = {
hostname: "localhost",
port: process.env.PORT || 3000,
path: "/health",
timeout: 2000,
};
const request = http.get(options, (res) => {
process.exit(res.statusCode === 200 ? 0 : 1);
});
request.on("error", () => process.exit(1));
request.end();Graceful Shutdown
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --only=production
# Use exec form for proper signal handling
CMD ["node", "server.js"]
# OR use tini for signal handling
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]// server.js - Handle graceful shutdown
process.on("SIGTERM", () => {
console.log("SIGTERM received, shutting down gracefully");
server.close(() => {
console.log("Server closed");
process.exit(0);
});
});Note
Tini is a minimal init system that properly handles signals and reaps zombie
processes. It's included in Docker's --init flag.
Labels for Metadata
FROM node:20-alpine
# OCI standard labels
LABEL org.opencontainers.image.title="My Application" \
org.opencontainers.image.description="Production web server" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.vendor="My Company" \
org.opencontainers.image.authors="team@company.com" \
org.opencontainers.image.source="https://github.com/company/app" \
org.opencontainers.image.licenses="MIT"
WORKDIR /app
COPY . .
CMD ["node", "server.js"]Complete Production Examples
Node.js Production Image
# syntax=docker/dockerfile:1
FROM node:20-alpine AS base
# Install tini for proper signal handling
RUN apk add --no-cache tini
WORKDIR /app
# Dependencies stage
FROM base AS dependencies
COPY package*.json ./
RUN npm ci
# Build stage
FROM dependencies AS builder
COPY . .
RUN npm run build
# Production stage
FROM base AS production
# Labels
LABEL org.opencontainers.image.title="My Node App" \
org.opencontainers.image.version="1.0.0"
# Create non-root user
RUN addgroup -S nodejs && adduser -S nodejs -G nodejs
# Copy only production dependencies
COPY --from=dependencies /app/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
# Set ownership
RUN chown -R nodejs:nodejs /app
USER nodejs
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]Python Production Image
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# Builder stage
FROM base AS builder
# Install build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential && \
rm -rf /var/lib/apt/lists/*
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Install dependencies
COPY requirements.txt .
RUN pip install -r requirements.txt
# Production stage
FROM base AS production
# Copy virtual environment
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Create non-root user
RUN useradd --create-home --shell /bin/bash appuser
# Copy application
COPY --chown=appuser:appuser . .
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "app:app"]Static Binary (Go)
# Build stage
FROM golang:1.22-alpine AS builder
# Install certificates
RUN apk --no-cache add ca-certificates
WORKDIR /app
# Download dependencies
COPY go.mod go.sum ./
RUN go mod download
# Build static binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags='-w -s -extldflags "-static"' \
-o /app/server .
# Final stage - scratch (empty) image
FROM scratch
# Copy certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy binary
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]Scratch Image Benefits
| Aspect | Alpine | Scratch |
|---|---|---|
| Size | ~7MB + app | Just app binary |
| Shell | Yes (sh) | No |
| Package manager | Yes (apk) | No |
| Attack surface | Small | Minimal |
| Debugging | Easier | Harder |
Checklist for Production Images
- Use multi-stage builds to minimize size
- Run as non-root user
- Use specific version tags (not
latest) - Include health checks
- Handle signals gracefully (SIGTERM)
- No secrets in image layers
- Scan for vulnerabilities regularly
- Use
.dockerignoreto exclude unnecessary files - Add meaningful labels (OCI standard)
- Order instructions for optimal caching
- Clean up in the same layer as installation
- Use minimal base images (slim/alpine/distroless)
Quick Reference
| Practice | Impact |
|---|---|
| Multi-stage builds | Smaller images, no build tools |
| Non-root user | Better security |
| Combine RUN commands | Fewer layers, smaller size |
| Order by change frequency | Better cache hits |
| Health checks | Container orchestration |
| Minimal base images | Smaller attack surface |
| .dockerignore | Faster builds, smaller context |
In the next chapter, we'll explore Docker networking and how containers communicate with each other and the outside world.