Docker Multi-Stage Builds — Reduce Image Size

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Docker Multi-Stage Builds — Reduce Image Size

Multi-stage builds separate the build process from runtime, dramatically reducing final image sizes. Learn to architect efficient production images.

Introduction

Multi-stage builds use multiple FROM statements in a single Dockerfile. Build artifacts stay in builder images while only necessary runtime files go into the final image.

Basic Multi-Stage Pattern

Traditional Single-Stage

# Problem: 1.2 GB image includes build tools
FROM node:18
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]

Size breakdown:

  • Base Node image: 1000 MB
  • Dependencies: 150 MB
  • Build artifacts: 50 MB
  • Total: 1.2 GB

Multi-Stage Build

# Stage 1: Builder (discarded after build)
FROM node:18 AS builder
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build

# Stage 2: Runtime (final image)
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]

Size breakdown:

  • Runtime image: 173 MB
  • Built artifacts: 50 MB
  • Total: 223 MB (81% reduction)

Advanced Multi-Stage Architecture

Three-Stage Build

# 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  # Includes dev dependencies
COPY . .
RUN npm run build

# Stage 3: Runtime
FROM node:18-alpine
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
USER 1001
EXPOSE 3000
CMD ["node", "dist/server.js"]

Language-Specific Examples

Go Application

# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

# Runtime stage
FROM gcr.io/distroless/base-debian11
COPY --from=builder /app/app /
EXPOSE 8080
CMD ["/app"]

Results:

  • With go:1.21: 800 MB
  • With distroless: 20 MB

Python Application

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

# Runtime stage
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY app.py .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]

Results:

  • Traditional: 900 MB
  • Multi-stage: 250 MB

Java Application

# Build stage
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn clean package

# Runtime stage
FROM eclipse-temurin:17-jre-alpine
COPY --from=builder /app/target/app.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"]

Frontend Application Example

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

# Runtime stage: serve with nginx
FROM nginx:alpine
# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Create nginx.conf:

events {
  worker_connections 1024;
}

http {
  server {
    listen 80;
    location / {
      root /usr/share/nginx/html;
      index index.html index.htm;
      try_files $uri $uri/ /index.html;
    }
    location /api/ {
      proxy_pass http://backend:3000;
    }
  }
}

Size comparison:

  • With Node runtime: 1.2 GB
  • With Nginx: 150 MB

Conditional Stages

Development vs Production

# Stage 1: Base
FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./

# Stage 2: Development (includes devDeps)
FROM base AS dev
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

# Stage 3: Production (only production deps)
FROM base AS prod-deps
RUN npm ci --only=production

# Stage 4: Production (final)
FROM base AS prod
COPY --from=prod-deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
USER 1001
EXPOSE 3000
CMD ["node", "dist/server.js"]

Build specific stage:

# Build dev image
docker build --target dev -t myapp:dev .

# Build prod image
docker build --target prod -t myapp:prod .

Optimized Build Example

# syntax=docker/dockerfile:1.4

# Stage 1: Builder
FROM node:18-alpine AS builder
WORKDIR /build

# Cache dependencies layer
COPY package*.json ./
RUN npm ci

# Copy source and build
COPY . .
RUN npm run build

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

# Stage 2: Runtime
FROM node:18-alpine AS runtime

# Security: non-root user
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

WORKDIR /app

# Copy artifacts
COPY --from=builder --chown=nodejs:nodejs /build/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /build/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /build/package*.json ./

USER nodejs

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
  CMD node healthcheck.js

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

Build Optimization with BuildKit

Enable experimental BuildKit features:

export DOCKER_BUILDKIT=1
docker build -t myapp:1.0 .

Advanced Dockerfile syntax:

# syntax=docker/dockerfile:1.4

FROM node:18-alpine AS builder
WORKDIR /app

# Cache mount: shared between builds
RUN --mount=type=cache,target=/root/.npm \
    npm ci

# Secrets during build
RUN --mount=type=secret,id=github_token \
    npm ci --registry="$(cat /run/secrets/github_token)"

COPY . .
RUN npm run build

FROM node:18-alpine
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]

Build with secrets:

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

Analyzing Image Layers

# View all layers and their sizes
docker history myapp:1.0

# Output:
# IMAGE               CREATED             CREATED BY                      SIZE
# a1b2c3d4            2 minutes ago       /bin/sh -c npm run build        45MB
# b2c3d4e5            2 minutes ago       COPY --from=builder /app ...    30MB
# c3d4e5f6            3 minutes ago       FROM node:18-alpine             173MB

# Inspect detailed image structure
docker inspect myapp:1.0 | jq '.RootFS.Layers'

# Compare image sizes
docker images myapp
# REPOSITORY   TAG       SIZE
# myapp        prod      95MB
# myapp        dev       800MB

Performance Impact

MetricTraditionalMulti-StageSavings
Image Size1.2 GB150 MB87% smaller
Build Time2m 30s1m 45s30% faster
Pull Time90s10s89% faster
Push Time60s5s92% faster

FAQ

Q: Why does multi-stage build reduce image size? A: Build tools (compilers, dev dependencies) are not copied to the final image. Only runtime dependencies and built artifacts are included.

Q: Can I use multiple FROM statements without multi-stage? A: Multiple FROM statements create multiple images. Multi-stage references previous stages with COPY --from=builder.

Q: Does multi-stage build take longer? A: Usually faster overall because smaller images push/pull quickly, offsetting slightly longer build time.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro