Multi-Stage Builds
Learn how to create smaller, faster, and more secure Docker images using multi-stage builds.
Multi-Stage Builds
One of the most effective ways to optimize Docker images is through multi-stage builds.
Multi-stage builds allow you to separate the build process from the final runtime image, resulting in:
- Smaller image sizes
- Faster deployments
- Reduced attack surface
- Cleaner production environments
For modern applications, multi-stage builds are considered a best practice.
Why Multi-Stage Builds Matter
Many applications require tools during the build process that are unnecessary at runtime.
For example:
Application Source Code
│
▼
Build Tools
├── Node.js
├── npm
├── TypeScript
├── Webpack
└── Build Dependencies
│
▼
Compiled Application
Once the application is built, those tools are no longer needed.
Without multi-stage builds, they remain inside the final image.
This increases:
- Image size
- Security risks
- Storage requirements
Traditional Docker Build
Consider a frontend application:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
While functional, this image contains:
- Source files
- Build dependencies
- Development tools
- Runtime dependencies
Everything is packaged together.
Understanding Multi-Stage Builds
Multi-stage builds introduce multiple FROM instructions.
Each stage serves a specific purpose.
Example:
FROM node:20 AS builder
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
Docker copies only the build output into the final image.
The build tools remain behind.
How Multi-Stage Builds Work
Build stage:
Source Code
│
▼
Builder Container
│
▼
Compiled Files
Runtime stage:
Compiled Files
│
▼
Production Image
Only the compiled artifacts are included in the final image.
Naming Build Stages
Stages can be named for clarity.
Example:
FROM node:20 AS builder
Later:
COPY --from=builder ...
This makes Dockerfiles easier to read and maintain.
Frontend Application Example
A React application:
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Final image contains:
Nginx
Built Static Files
It does not contain:
Node.js
npm
Source Code
This significantly reduces image size.
Node.js API Example
For backend applications:
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm install --omit=dev
CMD ["node", "dist/server.js"]
The final image includes only production dependencies.
Python Example
A Python application:
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install \
--target=/dependencies \
-r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /dependencies /dependencies
COPY . .
CMD ["python", "app.py"]
This keeps runtime images lean and focused.
Reducing Image Size
Consider:
Standard Build
├── Source Code
├── Build Tools
├── Dependencies
└── Runtime
Versus:
Multi-Stage Build
├── Runtime
└── Application
Image reductions of 50–90% are common.
Security Benefits
Smaller images often mean:
- Fewer packages
- Fewer vulnerabilities
- Smaller attack surface
Example:
Build Dependencies
├── Compilers
├── Package Managers
└── Debugging Tools
These are usually unnecessary in production.
Multi-stage builds remove them automatically.
Working with Distroless Images
Some teams take optimization even further.
Example:
FROM gcr.io/distroless/nodejs20
Distroless images contain only:
- Application runtime
- Required libraries
No shell.
No package manager.
No unnecessary tools.
This can improve security significantly.
Debugging Multi-Stage Builds
Build the image:
docker build -t my-app .
Verify image size:
docker images
Inspect contents:
docker run -it my-app sh
If the image lacks a shell, inspect the build stage instead.
Building a Specific Stage
Docker allows building an individual stage.
Example:
docker build \
--target builder \
-t my-app-builder .
Useful for:
- Debugging
- Testing build artifacts
- CI workflows
Combining with Docker Compose
Compose works normally with multi-stage builds.
Example:
services:
app:
build:
context: .
Docker automatically executes all stages and produces the final image.
No additional configuration is required.
Common Mistakes
Copying Too Much
Avoid:
COPY . .
without a proper .dockerignore.
Including Development Dependencies
Avoid shipping:
Testing Libraries
Debug Tools
Build Utilities
into production images.
Forgetting Production Installs
For Node.js:
RUN npm install --omit=dev
This removes development-only dependencies.
Using Large Runtime Images
Prefer:
node:20-alpine
instead of:
node:20
when appropriate.
Best Practices
Separate Build and Runtime
Keep compilation and execution stages independent.
Use Lightweight Runtime Images
Smaller images improve startup and deployment speed.
Name Stages Clearly
Examples:
AS builder
AS test
AS runtime
Clear naming improves maintainability.
Combine with .dockerignore
Reduce build context whenever possible.
Scan Final Images
Always inspect production images for vulnerabilities.
Example Production Workflow
Source Code
│
▼
Builder Stage
│
▼
Compiled Application
│
▼
Runtime Stage
│
▼
Production Image
│
▼
Docker Hub
│
▼
Deployment
This workflow is commonly used in modern CI/CD pipelines.
Summary
Multi-stage builds are one of the most powerful Docker optimization techniques.
Benefits include:
- Smaller images
- Faster deployments
- Improved security
- Cleaner runtime environments
- Reduced storage usage
For production workloads, multi-stage builds should be considered the default approach when building Docker images.