Learning Guides
Menu

Dockerfile Best Practices

10 min readDocker for Developers

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:

DOCKERFILE
# 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: ~800MB

The Solution

Use separate stages for building and running:

DOCKERFILE
# 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: ~150MB

Multi-Stage Size Comparison

ApproachImage SizeContents
Single stage~800MBNode, npm, all deps, source, build
Multi-stage~150MBNode, production deps, build output
Multi-stage (optimized)~50MBAlpine Node, minimal deps

Copying from Named Stages

DOCKERFILE
# 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

DOCKERFILE
# 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

DOCKERFILE
# 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

DOCKERFILE
# 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

DOCKERFILE
# 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

DOCKERFILE
# 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/python3

Base Image Comparison

Base ImageSizeShellPackage ManagerBest For
ubuntu:22.0477MBYesaptDevelopment
debian:bookworm-slim74MBYesaptGeneral use
alpine:3.197MBYesapkProduction
distroless2-20MBNoNoneSecure production
scratch0MBNoNoneStatic binaries

Use .dockerignore Effectively

BASH
# .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.js

Remove Unnecessary Files

DOCKERFILE
# 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 --production

Security Best Practices

Run as Non-Root

DOCKERFILE
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

DOCKERFILE
# 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://... myapp

Use BuildKit Secrets

DOCKERFILE
# 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"]
BASH
# Build with secret
docker build --secret id=npmrc,src=$HOME/.npmrc .

Scan for Vulnerabilities

BASH
# 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.0

Use Read-Only Filesystem

DOCKERFILE
# 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"]
BASH
# Run with read-only filesystem
docker run --read-only --tmpfs /app/tmp --tmpfs /app/logs myapp:1.0

Production Patterns

Health Checks

DOCKERFILE
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"]
JAVASCRIPT
// 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

DOCKERFILE
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"]
JAVASCRIPT
// 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

DOCKERFILE
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

DOCKERFILE
# 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

DOCKERFILE
# 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)

DOCKERFILE
# 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

AspectAlpineScratch
Size~7MB + appJust app binary
ShellYes (sh)No
Package managerYes (apk)No
Attack surfaceSmallMinimal
DebuggingEasierHarder

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 .dockerignore to 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

PracticeImpact
Multi-stage buildsSmaller images, no build tools
Non-root userBetter security
Combine RUN commandsFewer layers, smaller size
Order by change frequencyBetter cache hits
Health checksContainer orchestration
Minimal base imagesSmaller attack surface
.dockerignoreFaster builds, smaller context

In the next chapter, we'll explore Docker networking and how containers communicate with each other and the outside world.