Introduction

Writing efficient Dockerfiles is crucial for fast builds, secure containers, and optimal image sizes. This guide covers best practices for creating production-ready Dockerfiles.

Basic Dockerfile Structure

Simple Dockerfile

FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["npm", "start"]

Issues:

  • ❌ Copies source before dependencies
  • ❌ No layer optimization
  • ❌ Not using cache effectively
  • ❌ Missing security considerations

Best Practices

1. Use Specific Base Images

# ❌ Bad - uses latest (unpredictable)
FROM node:latest

# ✅ Good - specific version
FROM node:18-alpine

# ✅ Better - specific digest
FROM node:18-alpine@sha256:abc123...

Why Alpine?

  • Smaller image size (5MB vs 80MB+)
  • Security-focused
  • Still fully functional

2. Order Instructions for Caching

# ❌ Bad - invalidates cache on every code change
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

# ✅ Good - dependencies cached separately
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

Docker caches layers:

  • Cache invalidates when layer changes
  • Dependencies change less than code
  • Copy dependencies first!

3. Use Multi-Stage Builds

# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Stage 2: Runtime
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/index.js"]

Benefits:

  • Smaller final image (no build tools)
  • Better security (no dev dependencies)
  • Faster deployments

4. Minimize Layers

# ❌ Bad - many layers
RUN apt-get update
RUN apt-get install -y git
RUN apt-get install -y curl
RUN apt-get clean

# ✅ Good - single layer
RUN apt-get update && \
    apt-get install -y git curl && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

5. Use .dockerignore

Create .dockerignore:

node_modules
npm-debug.log
.git
.env
dist
*.md

Benefits:

  • Faster builds
  • Smaller context
  • Excludes sensitive files

6. Run as Non-Root User

# ❌ Bad - runs as root
FROM node:18-alpine
CMD ["node", "app.js"]

# ✅ Good - non-root user
FROM node:18-alpine
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001
USER nodejs
CMD ["node", "app.js"]

7. Use COPY Instead of ADD

# ❌ ADD has extra features (tar extraction, URLs)
ADD https://example.com/file.tar.gz /app/

# ✅ COPY is explicit and predictable
COPY local-file.txt /app/

Optimization Techniques

Reduce Image Size

# Multi-stage build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-alpine
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./
RUN npm ci --only=production && npm cache clean --force
USER nodejs
CMD ["node", "dist/index.js"]

Layer Caching Strategy

FROM node:18-alpine

# Layer 1: Install system dependencies (rarely changes)
RUN apk add --no-cache git python3 make g++

# Layer 2: Copy package files (changes occasionally)
COPY package*.json ./

# Layer 3: Install dependencies (changes with package.json)
RUN npm ci --only=production

# Layer 4: Copy application code (changes frequently)
COPY . .

# Layer 5: Build (changes with code)
RUN npm run build

Security Best Practices

1. Scan Base Images

docker scan node:18-alpine

2. Keep Images Updated

# Regularly update base images
FROM node:20-alpine  # Latest LTS

3. Don’t Store Secrets

# ❌ Bad - secrets in image
ENV API_KEY=secret123
COPY .env ./

# ✅ Good - use secrets at runtime
# docker run -e API_KEY=secret123 ...
# Or use Docker secrets

4. Minimal Attack Surface

# Only install what you need
RUN apt-get update && \
    apt-get install -y only-required-package && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

Language-Specific Examples

Node.js

FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-alpine
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./
RUN npm ci --only=production && npm cache clean --force
USER nodejs
EXPOSE 3000
CMD ["node", "dist/index.js"]

Python

FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt

FROM python:3.11-slim
WORKDIR /app
RUN useradd -m -u 1000 appuser
COPY --from=builder /root/.local /home/appuser/.local
COPY --chown=appuser:appuser . .
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
CMD ["python", "app.py"]

Go

FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/app .
CMD ["./app"]

Common Mistakes

❌ Don’t Do This

1. Using :latest tag

FROM node:latest  # ❌ Unpredictable

2. Installing unnecessary packages

RUN apt-get install -y build-essential git curl wget vim  # ❌ Too much

3. Not cleaning up

RUN apt-get install -y package
# ❌ Leaves apt cache

4. Exposing unnecessary ports

EXPOSE 22 80 443 3000 8000  # ❌ Only expose what's needed

Tools

Use our tools:

Other tools:

  • docker build with --no-cache to test
  • docker history to see layer sizes
  • dive to analyze image layers

Conclusion

Writing good Dockerfiles requires:

Key principles:

  • Specific base image versions
  • Optimal layer ordering
  • Multi-stage builds
  • Minimal image size
  • Security considerations
  • Non-root users

Remember:

  • Cache dependencies separately
  • Use multi-stage builds
  • Minimize layers
  • Scan for vulnerabilities
  • Keep images updated

Next Steps