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 Build | Multi-Stage Build |
| ~800 MB – 1.2 GB image | ~25 MB – 50 MB image |
| Compiler + tools included | Only final artifact kept |
| Source code exposed | No source code in image |
| Slower to pull & deploy | Fast pulls, fast deploys |
| Larger attack surface | Minimal 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
