A large image means slow downloads, more disk usage, and a bigger attack surface. Multi-stage builds solve all three.
The Problem: Images Carry Unnecessary Baggage
A typical Dockerfile for a Python application:
FROM python:3.12
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "main.py"]
The python:3.12 image weighs ~1 GB. It includes a compiler, header files, and the entire build toolchain. None of that is needed at runtime — only the finished code is.
The Idea Behind Multi-stage Builds
Split the build into stages:
- Builder stage — install dependencies, compile, prepare artifacts
- Runtime stage — copy only the finished result into a minimal image
Intermediate images never appear in the final output.
Syntax
# Stage 1: build
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt
# Stage 2: runtime
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
CMD ["python", "main.py"]
Key constructs:
- FROM ... AS builder — give a stage a name
- COPY --from=builder — copy files from another stage
- You can reference a stage by name or by index (--from=0)
Python Application Example
An application with compiled dependencies (e.g., psycopg2, Pillow):
# Build stage: compiler needed for C extensions
FROM python:3.12 AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Final image: no compiler
FROM python:3.12-slim
WORKDIR /app
# Copy installed packages
COPY --from=builder /install /usr/local
# Copy only the source code
COPY src/ ./src/
COPY main.py .
# Create a non-privileged user
RUN adduser --disabled-password --no-create-home appuser
USER appuser
CMD ["python", "main.py"]
Image size comparison:
python:3.12 → ~1.0 GB (base image)
python:3.12 + deps → ~1.3 GB (with dependencies)
python:3.12-slim + deps → ~200 MB (multi-stage result)
Node.js Example: Building a Frontend
Node.js projects often compile TypeScript or bundle React. At runtime only dist/ and production node_modules are needed.
# Stage 1: install all dependencies and build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json .
RUN npm ci # install all dependencies
COPY . .
RUN npm run build # compile TypeScript / bundle
# Stage 2: production dependencies only
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json .
RUN npm ci --omit=dev # production only
# Stage 3: final image
FROM node:20-alpine
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
For a frontend served by nginx:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build # output goes to /app/dist
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
The final image is nginx plus static files only. Node.js is completely absent.
Size:
node:20-alpine + devDeps + build → ~800 MB
nginx:alpine + static dist → ~25 MB
Real-world Size Comparisons
| Application | Before | After |
|---|---|---|
| Python API (with psycopg2) | 1.3 GB | 190 MB |
| React SPA | 850 MB | 25 MB |
| Node.js API | 600 MB | 120 MB |
| Go service | 800 MB | 12 MB |
Go deserves a special mention: a static binary is copied into scratch (an empty image):
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM scratch
COPY --from=builder /app/server /server
CMD ["/server"]
# Image size: ~8 MB
Building a Specific Stage
# Build up to a specific stage
docker build --target builder -t myapp:builder .
# Useful for debugging: open a shell in the builder image
docker run -it myapp:builder sh
Summary
Multi-stage builds are the standard for production images. A smaller image:
- downloads and deploys faster
- uses less space in the registry
- has a smaller attack surface (no compiler, no debuggers)
💬 Comments (0)
No comments yet
Be the first to share your opinion about this article!