Dockerfile Optimization — Smaller, Faster Images

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Dockerfile Optimization — Smaller, Faster Images

Optimized Dockerfiles build faster, deploy quicker, and consume less storage. Learn advanced techniques to create production-grade images efficiently.

Introduction

Large Docker images lead to slow deployments, higher bandwidth costs, and larger attack surfaces. Optimizing your Dockerfile is essential for scalable DevOps practices.

Understanding Image Layers

Each Dockerfile instruction creates a layer. Docker caches layers to speed up builds.

FROM node:18              # Layer 1: Base image
WORKDIR /app              # Layer 2: Working directory
COPY package*.json ./     # Layer 3: Copy files (frequently invalidates cache)
RUN npm ci                # Layer 4: Install dependencies
COPY . .                  # Layer 5: Copy app code (frequently invalidates cache)
RUN npm run build         # Layer 6: Build step
CMD ["node", "server.js"] # Layer 7: Default command

Layer Caching Strategy

Order instructions from least to most frequently changed:

Inefficient Approach

FROM node:18
WORKDIR /app

# Problem: Large COPY invalidates all following layers
COPY . .

# These run unnecessarily on every build
RUN npm ci
RUN npm run build

CMD ["node", "server.js"]

Optimized Approach

FROM node:18
WORKDIR /app

# Stable layer: only changes when dependencies change
COPY package*.json ./
RUN npm ci --only=production

# Variable layers: change with source code
COPY . .
RUN npm run build

CMD ["node", "server.js"]

Build times after first build:

  • Inefficient: 2+ minutes
  • Optimized: 5-10 seconds (using cache)

Base Image Selection

Size Comparison

ubuntu:22.0477 MB
debian:bookworm           → 124 MB
node:181000 MB
node:18-alpine            → 173 MB
python:3.11967 MB
python:3.11-alpine        → 129 MB
golang:1.21800 MB
golang:1.21-alpine        → 372 MB

Using Alpine Linux

# Before: 1000 MB
FROM node:18
RUN apt-get update && apt-get install -y curl
COPY . .
RUN npm ci --only=production
CMD ["node", "server.js"]

# After: 173 MB (82% reduction)
FROM node:18-alpine
RUN apk add --no-cache curl
COPY . .
RUN npm ci --only=production
CMD ["node", "server.js"]

Key differences with Alpine:

  • Uses apk instead of apt
  • Use --no-cache to prevent package caching
  • Some packages have different names

Multi-Stage Builds

Multi-stage builds separate build and runtime stages, keeping only necessary artifacts:

Build Context Reduces Size

# Traditional approach: 1.2 GB
FROM node:18
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/server.js"]

Multi-Stage Approach: 350 MB

# Stage 1: Builder
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# Stage 2: Runtime (lightweight)
FROM node:18-alpine
WORKDIR /app

# Copy only built artifacts from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

EXPOSE 3000
CMD ["node", "dist/server.js"]

Advanced Multi-Stage Example

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

# Stage 2: Builder
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# Stage 3: Runtime (production)
FROM node:18-alpine AS runtime
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./

RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
USER nodejs

EXPOSE 3000
HEALTHCHECK CMD node healthcheck.js
CMD ["node", "dist/server.js"]

Reducing Dependencies

Frontend Application

# Inefficient: 1.5 GB (includes build tools in final image)
FROM node:18
WORKDIR /app
COPY . .
RUN npm ci && npm run build
CMD ["npm", "start"]

# Optimized: 80 MB (build tools removed)
FROM node:18-alpine AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Using distroless Images

# For Go applications: 50 MB
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o app

FROM gcr.io/distroless/base
COPY --from=builder /app/app /app
CMD ["/app"]

Combining Techniques

Complete Optimization Example

# Build stage
FROM node:18-alpine AS builder
WORKDIR /build

# Copy package files only
COPY package*.json ./

# Install all dependencies (including dev)
RUN npm ci

# Copy source code
COPY . .

# Build application
RUN npm run build

# Prune dev dependencies
RUN npm prune --only=production

# Runtime stage
FROM node:18-alpine

# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init

# Create non-root user
RUN addgroup -g 1001 app && \
    adduser -D -u 1001 -G app app

WORKDIR /app

# Copy only necessary files
COPY --from=builder --chown=app:app /build/dist ./dist
COPY --from=builder --chown=app:app /build/node_modules ./node_modules
COPY --from=builder --chown=app:app /build/package.json ./

USER app

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=10s --start-period=5s \
  CMD node healthcheck.js || exit 1

ENTRYPOINT ["/usr/sbin/dumb-init", "--"]
CMD ["node", "dist/server.js"]

Build Optimization Tips

Parallel Builds

# Buildkit enables parallel stages
DOCKER_BUILDKIT=1 docker build -t myapp:1.0 .

BuildKit Features

# Syntax directive (requires BuildKit)
# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS builder
WORKDIR /app

# Mount secrets during build (not in final image)
RUN --mount=type=secret,id=npm_token \
    echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > .npmrc && \
    npm ci

COPY . .
RUN npm run build

Build with secrets:

DOCKER_BUILDKIT=1 docker build \
  --secret npm_token=/path/to/token \
  -t myapp:1.0 .

.dockerignore

# Version control
.git
.gitignore

# Dependencies
node_modules
package-lock.json

# Build artifacts
dist
build
.next

# Testing
coverage
test

# Environment
.env
.env.local

# IDE
.vscode
.idea
.DS_Store

# OS
Thumbs.db

Benchmarking Optimization

# Build and check image size
docker build -t myapp:v1 .
docker images myapp:v1

# Compare with previous version
docker images | grep myapp

# Inspect layers
docker history myapp:v1

Example output:

IMAGE               CREATED             CREATED BY                                      SIZE
a1b2c3d4            2 minutes ago       /bin/sh -c dumb-init node dist/server.js       5MB
b2c3d4e5            2 minutes ago       WORKDIR /app                                     0B
c3d4e5f6            3 minutes ago       COPY --from=builder /build/package.json ./       4MB

Performance Gains

Typical optimization results:

AspectBeforeAfterImprovement
Image Size1.2 GB200 MB83% smaller
Build Time120s8s93% faster
Push Time2 min10s92% faster
Pull Time1.5 min8s94% faster

FAQ

Q: Should I always use Alpine Linux? A: Alpine is great for most cases but has limitations. Some packages have different names or behavior. Test thoroughly with Alpine before production.

Q: What's the downside of multi-stage builds? A: Slightly more complex Dockerfile syntax and build takes longer. Performance benefits outweigh these minor downsides.

Q: How do I know if my image is optimized? A: Use docker history to identify large layers. If base image plus dependencies is <500MB for most apps, you're on track.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro