Skip to main content
Docker·9 min read

Docker Multi-Stage Builds — Reduce Image Size from 1GB to 50MB

Learn how to use Docker multi-stage builds to create minimal production images. Covers Node.js, Go, Java, and Python examples with build caching, security hardening, and distroless base images.

DT

DevOps Engineer & Technical Writer

The Problem

DOCKER MULTI-STAGE BUILD — SIZE REDUCTION FLOW STAGE 1: Build SOURCE CODE App Source DEPENDENCIES node_modules BUILD TOOLS Compiler/Bundler OUTPUT Built Artifact ~1.2 GB COPY --from STAGE 2: Runtime MINIMAL BASE Alpine / Distroless ARTIFACT ONLY Built App Binary ~50 MB 96% size reduction

Your Docker image is 1.2GB. It contains build tools, source code, dev dependencies, and compilation artifacts that have no business being in production. Large images mean slow deployments, increased storage costs, larger attack surface, and longer pod startup times in Kubernetes.

Multi-stage builds let you use multiple FROM statements in a single Dockerfile. Build in one stage, copy only the artifacts you need to a minimal final stage. The result: images that are 10-50x smaller.

How Multi-Stage Builds Work

A multi-stage Dockerfile has multiple FROM instructions. Each FROM starts a new build stage. You can copy files between stages with COPY --from=stage_name:

# Stage 1: Build

FROM node:20 AS builder

WORKDIR /app

COPY package*.json ./

RUN npm ci

COPY . .

RUN npm run build

# Stage 2: Production (only contains built output)

FROM node:20-slim

WORKDIR /app

COPY --from=builder /app/dist ./dist

COPY --from=builder /app/node_modules ./node_modules

COPY --from=builder /app/package.json ./

EXPOSE 3000

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

The final image only contains what was explicitly copied from the builder stage.

Node.js Multi-Stage Build

Before (single stage) — ~1.1GB

FROM node:20

WORKDIR /app

COPY . .

RUN npm install

RUN npm run build

EXPOSE 3000

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

After (multi-stage) — ~180MB

FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./

RUN npm ci --only=production --ignore-scripts && \

cp -R node_modules prod_modules && \

npm ci && \

npm run build

FROM node:20-alpine AS production

WORKDIR /app

RUN addgroup -g 1001 -S appgroup && \

adduser -S appuser -u 1001 -G appgroup

COPY --from=builder /app/prod_modules ./node_modules

COPY --from=builder /app/dist ./dist

COPY --from=builder /app/package.json ./

USER appuser

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s \

CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

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

Key techniques:

  • Alpine base image (5MB vs 900MB for full Debian)
  • Separate production dependencies from dev dependencies
  • Non-root user for security
  • Health check built into the image

Go Multi-Stage Build

Go compiles to a static binary — the final image can be scratch (0 bytes base):

Production Go Dockerfile — ~12MB final image

FROM golang:1.22-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./

RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/server ./cmd/server

FROM scratch

COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

COPY --from=builder /app/server /server

EXPOSE 8080

ENTRYPOINT ["/server"]

If you need shell access for debugging, use distroless instead of scratch:

FROM gcr.io/distroless/static-debian12

COPY --from=builder /app/server /server

EXPOSE 8080

ENTRYPOINT ["/server"]

Java Multi-Stage Build

Spring Boot — from 800MB to 200MB

FROM eclipse-temurin:21-jdk-alpine AS builder

WORKDIR /app

COPY pom.xml .

COPY .mvn .mvn

COPY mvnw .

RUN chmod +x mvnw && ./mvnw dependency:resolve

COPY src ./src

RUN ./mvnw package -DskipTests -Dmaven.javadoc.skip=true

RUN java -Djarmode=layertools -jar target/*.jar extract --destination extracted

FROM eclipse-temurin:21-jre-alpine

WORKDIR /app

RUN addgroup -g 1001 -S spring && \

adduser -S spring -u 1001 -G spring

COPY --from=builder /app/extracted/dependencies/ ./

COPY --from=builder /app/extracted/spring-boot-loader/ ./

COPY --from=builder /app/extracted/snapshot-dependencies/ ./

COPY --from=builder /app/extracted/application/ ./

USER spring

EXPOSE 8080

ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

The layered extraction means Docker can cache dependency layers separately from application code.

Python Multi-Stage Build

FROM python:3.12-slim AS builder

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \

gcc \

libpq-dev \

&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt .

RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.12-slim

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \

libpq5 \

&& rm -rf /var/lib/apt/lists/*

COPY --from=builder /install /usr/local

RUN useradd -r -s /sbin/nologin appuser

COPY . .

USER appuser

EXPOSE 8000

CMD ["gunicorn", "app:create_app()", "--bind", "0.0.0.0:8000", "--workers", "4"]

Build Cache Optimization

Layer ordering dramatically affects build speed:

FROM node:20-alpine AS builder

WORKDIR /app

# Layer 1: Package files change rarely — cached most of the time

COPY package.json package-lock.json ./

RUN npm ci

# Layer 2: Source code changes frequently — only this rebuilds

COPY src/ ./src/

COPY tsconfig.json ./

RUN npm run build

Using BuildKit cache mounts

# syntax=docker/dockerfile:1

FROM node:20-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json ./

RUN --mount=type=cache,target=/root/.npm npm ci

COPY . .

RUN npm run build

# Enable BuildKit

DOCKER_BUILDKIT=1 docker build -t myapp .

Cache mounts for Go

# syntax=docker/dockerfile:1

FROM golang:1.22-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./

RUN --mount=type=cache,target=/go/pkg/mod go mod download

COPY . .

RUN --mount=type=cache,target=/root/.cache/go-build \

CGO_ENABLED=0 go build -o /server ./cmd/server

Security Hardening

Use distroless images

Distroless images contain only your application and its runtime dependencies — no shell, no package manager:

FROM gcr.io/distroless/nodejs20-debian12

COPY --from=builder /app/dist /app/dist

COPY --from=builder /app/node_modules /app/node_modules

WORKDIR /app

CMD ["dist/server.js"]

Scan for vulnerabilities

docker scout cves myapp:latest

trivy image myapp:latest

trivy image --severity CRITICAL,HIGH myapp:latest

Measuring Image Size

# Check image size

docker images myapp

# Analyze layers

docker history myapp:latest

# Detailed analysis with dive

dive myapp:latest

# Compare before and after

docker images --format "{{.Repository}}:{{.Tag}} {{.Size}}" | grep myapp

Common Mistakes

  • Copying node_modules from host — Add node_modules to .dockerignore. Installing inside the container ensures consistent, platform-appropriate binaries.
  • Not using .dockerignore — Without it, COPY . . sends .git, node_modules, test files, and documentation to the build context.
  • Installing dev dependencies in the final stage — Use npm ci --only=production or separate the dependency install step.
  • Not pinning base image versionsFROM node:latest can break builds when a new version releases. Use FROM node:20.11-alpine.
  • Running as root — Always add a non-root USER directive in the final stage.
  • Ignoring layer caching order — Put COPY package*.json before COPY . . so dependency installation is cached when only source code changes.
  • Quick Reference

    TechniqueImage Size Impact
    Alpine base-700MB (vs Debian)
    Multi-stage build-500MB to -1GB
    Production deps only-100MB to -300MB
    Distroless baseAdditional -50MB
    <code class="inline-code">--ldflags=&quot;-w -s&quot;</code> (Go)-30% binary size
    Scratch base (Go)Final image = binary size only
    <code class="inline-code">.dockerignore</code>Faster builds, smaller context
    BuildKit cache mounts2-5x faster rebuilds

    Summary

    Multi-stage builds are the single most impactful optimization for Docker images. Separate your build environment from your runtime environment. Use Alpine or distroless base images for the final stage. Order your layers for maximum cache efficiency. The result is smaller images that deploy faster, use less storage, and have a minimal attack surface.

    ---

    Frequently Asked Questions

    What is a Docker multi-stage build?

    A multi-stage build uses multiple FROM statements in a single Dockerfile, allowing you to use one stage for building/compiling and another for the final runtime image. Only the final stage is included in the produced image, dramatically reducing size by excluding build tools, source code, and intermediate artifacts.

    How does COPY --from work in multi-stage builds?

    COPY --from=builder /app/dist ./dist copies files from a named build stage (defined with FROM node:18 AS builder) into the current stage. You can also copy from external images using COPY --from=nginx:alpine /etc/nginx/nginx.conf ./. This lets you cherry-pick only the artifacts needed for runtime.

    How much can multi-stage builds reduce image size?

    Multi-stage builds typically reduce image sizes by 50-95%. A Node.js application might go from 1.2GB (with node_modules and build tools) to 100-200MB (with just production dependencies). A Go application can shrink from 800MB to 10-20MB by copying only the compiled binary to a scratch or distroless base image.

    When should I use multi-stage builds versus separate Dockerfiles?

    Always prefer multi-stage builds over separate Dockerfiles. They keep your build pipeline in a single file, ensure reproducibility, and work with any CI system without requiring intermediate image management. Separate Dockerfiles are only warranted when build stages need different build contexts or when you're sharing intermediate images across multiple final images.

    What is the best base image for the final stage?

    Use Alpine (5MB) for a minimal Linux environment with a package manager, or Google's distroless images for maximum security with no shell or package manager. For Go applications, scratch (0MB) works since Go compiles to static binaries. For Node.js or Python, slim variants offer a good balance between size and compatibility.

    ---