Skip to main content
Docker advanced Lesson 7 of 9

Multi-Stage Builds

Use multi-stage Dockerfiles to produce small, secure production images by separating build and runtime environments.

The Problem with Single-Stage Builds

A naive Dockerfile includes everything needed to build the app:

FROM node:18
WORKDIR /app
COPY . .
RUN npm install          # includes devDependencies
RUN npm run build
CMD ["npm", "start"]
# Final image: ~1.2GB — includes build tools, source maps, test deps

Multi-Stage Dockerfile

# ── Stage 1: Install all dependencies and build ──────────────────
FROM node:18-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci                          # all deps including dev
COPY . .
RUN npm run build                   # produces /app/dist

# ── Stage 2: Production image ────────────────────────────────────
FROM node:18-alpine AS production

WORKDIR /app

# Copy only production dependency manifest
COPY package*.json ./
RUN npm ci --only=production        # no devDependencies

# Copy compiled output from builder stage
COPY --from=builder /app/dist ./dist

# Run as non-root
USER node

EXPOSE 3000
CMD ["node", "dist/index.js"]
# Final image: ~120MB — no source, no devDeps, no build tools

Targeting a Stage

# Build the production stage (default — last FROM)
docker build -t myapp:prod .

# Build only the builder stage (useful for running tests in CI)
docker build --target builder -t myapp:test .

# Run tests against the builder stage
docker run --rm myapp:test npm test

Go: From 800MB to 10MB

# Stage 1: compile
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 server ./cmd/server

# Stage 2: scratch image — literally empty, just the binary
FROM scratch AS production

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

EXPOSE 8080
ENTRYPOINT ["/server"]
# Image size: ~10MB (just the binary + TLS certs)

Java Spring Boot

# Stage 1: build with Maven
FROM eclipse-temurin:21-jdk-alpine AS builder

WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN ./mvnw package -DskipTests

# Stage 2: extract layers for better caching (Spring Boot 2.3+)
FROM eclipse-temurin:21-jdk-alpine AS extractor
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# Stage 3: lean runtime
FROM eclipse-temurin:21-jre-alpine AS production
WORKDIR /app
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Reusing Stages as a Base

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

FROM base AS dev-deps
RUN npm ci

FROM base AS prod-deps
RUN npm ci --only=production

FROM dev-deps AS builder
COPY . .
RUN npm run build

FROM node:18-alpine AS production
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]

Build Arguments Across Stages

ARG NODE_VERSION=18

FROM node:${NODE_VERSION}-alpine AS builder
# ARG must be redeclared in each stage to be accessible there
ARG NODE_VERSION
RUN echo "Building with Node ${NODE_VERSION}"
docker build --build-arg NODE_VERSION=20 -t myapp:node20 .

CI/CD Integration

# In CI: build test stage, run tests, then build production
docker build --target builder -t myapp:test .
docker run --rm myapp:test npm test

docker build --target production -t myapp:${GIT_SHA} .
docker push myapp:${GIT_SHA}

Frequently Asked Questions

Why are multi-stage builds important?
Build tools (compilers, test runners, npm, Maven) can add hundreds of MBs to an image. Multi-stage builds let you compile in a full build environment and copy only the final artifact into a minimal runtime image, dramatically reducing image size and attack surface.
Can I have more than two stages?
Yes — you can have as many stages as needed. Common patterns: deps → test → build → production. Each stage can be targeted individually with --target.
How much smaller are multi-stage images in practice?
Typically 5–10x smaller. A Node.js app might go from 1.2GB (full node image with devDependencies) to 120MB (node:alpine with only production deps). A compiled Go binary goes from ~800MB to ~10MB on scratch.