Docker Multi-Stage Builds

What is a Multi-Stage Build?

A multi-stage build uses multiple FROM statements in a single Dockerfile. Each FROM starts a new build stage. You can copy files from one stage to another, leaving behind everything you don't need in the final image.

The result: Smaller, leaner production images.

The Problem Without Multi-Stage Builds

When you build a Node.js or Go application, you need:

  • Build tools (compiler, npm, make)
  • Source code
  • Test files
  • Development dependencies

But in production, you only need:

  • The compiled/built output
  • Runtime libraries
# Without multi-stage - EVERYTHING ends up in the image
FROM node:20
WORKDIR /app
COPY . .
RUN npm install       # includes dev dependencies (huge!)
RUN npm run build
CMD ["node", "dist/server.js"]

Problem: The image includes node_modules (with dev deps), source code, build tools - it can be 1 GB+.

Multi-Stage Build Solution

# Stage 1: Build (we call it "builder")
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci                          # install all dependencies
COPY . .
RUN npm run build                   # compile to /app/dist

# Stage 2: Production (starts fresh, tiny image)
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production        # production dependencies only
COPY --from=builder /app/dist ./dist  # copy ONLY the built output

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

Result: The final image only has Alpine Linux + production node_modules + built output = ~150 MB instead of ~1 GB.

Key Syntax

Naming a Stage

FROM ubuntu:22.04 AS my-stage-name

Use AS <name> to give a stage a name. Names are lowercase.

Copying From a Previous Stage

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

Copying From an External Image

# Copy a specific file from an official image
COPY --from=nginx:alpine /etc/nginx/nginx.conf /default-nginx.conf

Building Specific Stages

# Build the full Dockerfile (default - builds the last stage)
docker build -t my-app .

# Build only up to a specific stage (useful for debugging)
docker build --target builder -t my-app-debug .
docker build --target production -t my-app .

Real-World Examples

Example 1 - Node.js App

#  Stage 1: Install and Build
FROM node:20 AS builder
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

#  Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app

# Only production deps
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

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

# Non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

EXPOSE 3000
HEALTHCHECK --interval=30s CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]

Example 2 - Go Application (Produces a Single Binary)

#  Stage 1: Build
FROM golang:1.21 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: Minimal Runtime
FROM scratch AS production
# "scratch" = completely empty image - just the binary
COPY --from=builder /app/server /server

EXPOSE 8080
CMD ["/server"]

Result: A Docker image that is just the Go binary - often under 15 MB.

Example 3 - Python App

#  Stage 1: Build dependencies
FROM python:3.11 AS builder
WORKDIR /app

COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

#  Stage 2: Production
FROM python:3.11-slim AS production
WORKDIR /app

# Copy installed packages from builder
COPY --from=builder /root/.local /root/.local

COPY . .

# Non-root user
RUN useradd -m appuser
USER appuser

ENV PATH=/root/.local/bin:$PATH
EXPOSE 5000
CMD ["python", "app.py"]

Example 4 - React Frontend

#  Stage 1: Build React App
FROM node:20-alpine AS builder
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build    # creates /app/build/ or /app/dist/

#  Stage 2: Serve with Nginx
FROM nginx:alpine AS production

# Remove default nginx page
RUN rm -rf /usr/share/nginx/html/*

# Copy React build output
COPY --from=builder /app/build /usr/share/nginx/html

# Custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Example 5 - Three Stages (Test + Build + Production)

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

#  Stage 2: Test
FROM deps AS tester
COPY . .
RUN npm run lint
RUN npm run test

#  Stage 3: Production Build
FROM deps AS builder
COPY . .
RUN npm run build

#  Stage 4: Final Production Image
FROM node:20-alpine AS production
WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

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

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

Build only tests (skip production build):

docker build --target tester -t my-app-test .

Build production (skips tests if you want):

docker build --target production -t my-app .

Build Cache with Multi-Stage Builds

Docker caches each layer. Order your instructions to maximize cache hits.

#  GOOD - dependencies cached separately from code
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./          # <- cache this layer
RUN npm ci                     # <- only re-runs if package.json changes
COPY . .                       # <- code changes often - put after npm install
RUN npm run build

#  BAD - any code change invalidates the npm install cache
FROM node:20 AS builder
WORKDIR /app
COPY . .                       # <- code change here...
RUN npm ci                     # <- ...forces npm install to re-run every time
RUN npm run build

Size Comparison

ApproachImage Size
Single stage with node:20~1.1 GB
Multi-stage with node:20-alpine~200 MB
Multi-stage with scratch (Go binary)~10-20 MB

Commands for Multi-Stage Builds

# Build final image (default - last stage)
docker build -t my-app .

# Build a specific stage
docker build --target builder -t my-app-build .
docker build --target production -t my-app .

# Build without cache (force fresh build)
docker build --no-cache -t my-app .

# Build with verbose output (see each step)
docker build --progress=plain -t my-app .

# Build and inspect size
docker build -t my-app . && docker images my-app

# Build with build args
docker build --build-arg VERSION=1.0 -t my-app .

Multi-Stage Builds Quick Summary

FeatureWhat it does
Multiple FROMStarts a new build stage
AS <name>Names a stage for reference
COPY --from=<name>Copies files from a previous stage
--target <name>Builds only up to the named stage
FROM scratchEmpty base - for single-binary apps

Key benefit: Only the last FROM stage's files end up in the final image. Everything in earlier stages is discarded - making production images much smaller and more secure.

-> You have completed the Docker series! Review README.md for the full learning path.

FAQ

Should I memorize every Docker command?+

No. Memorize the core workflow first: build, run, list, inspect, logs, exec, stop, remove, and clean up. Then learn specialized commands when you need them.

Is Docker only for developers?+

No. Docker is useful for system administrators, infrastructure engineers, DevOps engineers, cloud engineers, support engineers, and learners who want repeatable labs.

What should I do after reading this guide?+

Run the examples, write down what each command changes, rebuild the workflow with Docker Compose, and then add one CI/CD step that builds the image automatically.

Need help applying Docker in a real project?

Work directly with Muhammad Irfan Aslam for Docker, Linux, DevOps, CI/CD, cloud deployment, or infrastructure troubleshooting support.

Hire Me for Support