Docker Multi-Stage Builds Explained

Build Smaller, Faster, and More Secure Docker Images

What is a Multi-Stage Build?

Imagine you’re baking a cake. You need flour, eggs, butter, mixing bowls, and a whisk — but when you serve the cake to your guests, you don’t hand them the mixing bowls too. You only give them the final cake.

Docker multi-stage builds work exactly the same way. When building software there are always two distinct phases:

  • Build phase — bring in all the tools (compilers, dependencies, scripts) to create your app
  • Final phase — take only the finished app and put it in a clean, minimal container — no tools, no clutter

The result: a tiny, secure, production-ready Docker image.

💡 Why does this matter? Without multi-stage builds, a Node.js app image can weigh over 1 GB — all because the compiler, npm, and build tools are baked in. With multi-stage builds the same app can ship in under 30 MB. That means faster deployments, lower bandwidth costs, and a much smaller security attack surface.

Single-Stage vs Multi-Stage — At a Glance

Here’s how the two approaches compare side by side:

Single-Stage BuildMulti-Stage Build
~800 MB – 1.2 GB image~25 MB – 50 MB image
Compiler + tools includedOnly final artifact kept
Source code exposedNo source code in image
Slower to pull & deployFast pulls, fast deploys
Larger attack surfaceMinimal attack surface

How Multi-Stage Builds Work — The Concept

Stage 1: The Builder

The first stage uses a full-featured base image (e.g. node:18, golang:1.21, maven:3.9). This is where all the heavy lifting happens — installing dependencies, compiling code, bundling assets. Think of it as your workshop: messy, full of tools, but necessary.

Stage 2: The Final Image

The second stage starts completely fresh from a minimal base image (e.g. nginx:alpine, alpine:3.19). You selectively copy only the compiled output from Stage 1 using a single instruction. Everything else — the compiler, dev dependencies, source code — is automatically discarded by Docker.

The Bridge: COPY –from

The magic is in one line of syntax:

COPY –from=builder /app/dist /usr/share/nginx/html

The –from=builder flag tells Docker to reach back into the builder stage and grab a specific folder. Without this, the two stages would have no connection at all.

🎯 The Golden Rule Only what you explicitly COPY –from makes it into the final image. Everything else in the build stage vanishes. This is by design — Docker never stores intermediate stages as images unless you ask it to.

Multi-Stage Dockerfile — Core Syntax

Here is the essential pattern every multi-stage Dockerfile follows. Study this before writing any code:

# Stage 1 — name it with AS so you can reference it later
FROM node:18 AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build
 
# Stage 2 — start fresh from a tiny base image
FROM nginx:alpine
 
# Bridge: copy ONLY the built output from Stage 1
# Syntax: COPY –from=<stage-name> <src> <dest>
COPY –from=builder /app/dist /usr/share/nginx/html
 
# Expose the port so the app is accessible
EXPOSE 80

The three rules to remember:

  • Rule 1 — Name your stages with AS (e.g. FROM node:18 AS builder)
  • Rule 2 — Use COPY –from=<name> to carry output from one stage to the next
  • Rule 3 — The last FROM in your Dockerfile is the one Docker saves as the final image

Step-by-Step Practical Demo

Let’s build a real working example from scratch. We’ll create a simple HTML app, containerize it with a multi-stage Dockerfile, run it, and verify the image size. No frameworks required — just Docker.

Step 1  [Setup] Create the project folder

Open your terminal and run:

# Create and enter your project directory
mkdir docker-multistage-demo
cd docker-multistage-demo
 
# We will create 3 files:
#   index.html     — our simple web app
#   Dockerfile     — the multi-stage build instructions
#   .dockerignore  — keeps the build context lean
💡 Tip You don’t need Node.js, a compiler, or any build tools installed locally. Docker handles everything inside the container during the build. Your machine stays clean!
Step 2  [Code] Create index.html — your app

Create a file called index.html in your project folder with the following content:

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Multi-Stage Docker Demo</title>
  <style>
    body {
      font-family: sans-serif;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      margin: 0;
      background: #f0f4f8;
    }
    .card {
      background: white;
      padding: 2rem 3rem;
      border-radius: 12px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.08);
      text-align: center;
    }
    h1 { color: #1a56db; margin: 0 0 8px; }
    p  { color: #6b7280; margin: 0; }
  </style>
</head>
<body>
  <div class="card">
    <h1>Hello from Docker!</h1>
    <p>Served via a multi-stage build. Image is tiny.</p>
  </div>
</body>
</html>
📝 Note In a real project this would be a React, Vue, or Angular app with npm install followed by npm run build. We are keeping it simple here so you can focus entirely on the Docker concepts without any framework noise.
Step 3  [Core Concept] Write the multi-stage Dockerfile

Create a file called Dockerfile (no file extension) in your project folder:

# ─────────────────────────────────────────────
# STAGE 1: builder
# Purpose: copy our source files, prepare them
# ─────────────────────────────────────────────
FROM alpine:3.19 AS builder

# Set working directory inside the container
WORKDIR /app

# Copy our source code into the builder stage
COPY index.html .

# In a real app you'd run:
#   RUN npm install && npm run build
# For this demo, our file is already ready.
# We'll just create a dist/ folder manually:
RUN mkdir dist && cp index.html dist/index.html


# ─────────────────────────────────────────────
# STAGE 2: final image
# Purpose: serve only the built output
# Base image: nginx:alpine (~25 MB — tiny!)
# ─────────────────────────────────────────────
FROM nginx:alpine

# Copy ONLY the built output from Stage 1
# Everything else (alpine, build tools) is discarded
COPY --from=builder /app/dist /usr/share/nginx/html

# Expose port 80 so we can access the site
EXPOSE 80

# nginx starts automatically — no CMD needed
🔑 Key Line COPY –from=builder is the bridge between stages. It is the only thing that crosses over. Everything else in the builder stage is automatically thrown away by Docker.
Step 4  [Best Practice] Add a .dockerignore file

Create a file called .dockerignore in your project folder. This tells Docker what not to include in the build context:

# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
*.md
.DS_Store
dist
💡 Think of .dockerignore like .gitignore It prevents unnecessary files from being sent to Docker during the build, which makes builds faster and reduces context size. Always include node_modules and .git at minimum.
Step 5  [Run It] Build the Docker image

From inside your project directory, run the following command:

# Build the image and tag it as ‘my-app’
docker build -t my-app .
 
# You will see Docker process both stages:
# Step 1/7: FROM alpine:3.19 AS builder
# Step 2/7: WORKDIR /app
# Step 3/7: COPY index.html .
# Step 4/7: RUN mkdir dist && cp index.html dist/index.html
# Step 5/7: FROM nginx:alpine          <– Stage 2 starts here!
# Step 6/7: COPY –from=builder …
# Step 7/7: EXPOSE 80
 
# Then check the final image size:
docker images my-app
👀 Watch the output Notice how Docker clearly shows both stages as it builds. After the build completes, the docker images command will show the final image size — it should be around 25 to 50 MB, not hundreds of megabytes!
Step 6  [Run It] Run the container

Start a container from your newly built image:

# Run the container
# -d  run in background (detached mode)
# -p  map port 8080 on your machine to port 80 inside the container
docker run -d -p 8080:80 –name my-running-app my-app
 
# Then open your browser and visit:
#   http://localhost:8080
 
# To see all running containers:
docker ps
 
# To stop the container:
docker stop my-running-app
 
# To remove it completely:
docker rm my-running-app
🎉 You’re done! Your app is now running inside a clean nginx container. No Node.js, no compiler, no build tools, no source code — just the final HTML file being served by nginx from a 25 MB image.
Step 7  [Key Insight] Verify the size savings

Let’s see what the numbers actually look like:

# Without multi-stage (using node:18 as the only base):
# node:18 image alone      = ~1.1 GB
# + your app and modules   = ~1.3 GB total
 
# With multi-stage (nginx:alpine as final base):
# nginx:alpine alone       = ~25 MB
# + your dist files        = ~26 MB total
 
# Check your actual image size:
docker images my-app
 
# REPOSITORY   TAG      IMAGE ID       SIZE
# my-app       latest   abc123…      26.3MB
 
# The builder stage is NOT stored as an image.
# Docker discards it automatically after the build.
📊 The impact at scale A 50x smaller image means: faster CI/CD pipelines, lower container registry storage costs, quicker Kubernetes pod startup times, smaller attack surface for security audits, and happier DevOps engineers. Multi-stage builds are not optional in production — they are standard practice.

Quick Reference — The 3 Rules

Bookmark these three rules. They cover everything you need to know about multi-stage syntax:

  • Rule 1 — Name your stages. Add AS somename to your FROM line. Example: FROM node:18 AS builder
  • Rule 2 — Use COPY –from=<stage>. This is the only way to carry output from one stage to the next. Example: COPY –from=builder /app/dist .
  • Rule 3 — The last FROM wins. Docker saves only the final stage as your image. All earlier stages are temporary workspaces discarded automatically.

When Should You Use Multi-Stage Builds?

Multi-stage builds are ideal whenever there is a ‘build step’ that produces a smaller output artifact. Here are the best use cases:

  • React, Vue, Angular apps — npm install + npm run build produces a dist/ folder
  • Go binaries — go build produces a single static binary that can run on alpine:3.19
  • Java Spring Boot — Maven or Gradle produces a JAR that runs on eclipse-temurin:21-jre
  • Rust applications — cargo build –release produces a binary that runs on debian:slim
  • TypeScript projects — tsc compiles to plain JS that runs on node:18-alpine

For simple Python or Ruby scripts with no compilation step, a single stage is usually sufficient. The rule of thumb: if there’s a build step that transforms your source into something smaller, multi-stage builds will help.

Wrapping Up

Docker multi-stage builds are one of the simplest yet highest-impact practices in modern container development. With just a few extra lines in your Dockerfile you can go from a 1.3 GB bloated image to a lean 26 MB production container — no extra tools, no scripts, no CI magic required.

To recap what we covered:

  • What multi-stage builds are and why they exist
  • The core concept: build stage does the work, final stage keeps only the result
  • The complete Dockerfile syntax with AS and COPY –from
  • A full working demo you can run right now
  • How to verify image size savings
Scroll to Top