📝 Docker

Multi-stage Builds: Shrinking Your Docker Image

P
Author
Pyland
📅
Published
30.06.2026
⏱️
Reading time
3 min
👁️
Views
81
🌳
Level
Advanced

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:

  1. Builder stage — install dependencies, compile, prepare artifacts
  2. 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)

Your reaction to the article

💬 Comments (0)

🔐 Sign in to leave a comment
🚪 Login
💭

No comments yet

Be the first to share your opinion about this article!

🔗 Similar

Similar articles

Continue learning with these materials

📝

Deploying FastAPI with Docker

Railway will also automatically detect a Dockerfile if one is present.

📅 30.06.2026 👁️ 83
📝

Docker Compose: Advanced Features

A basic docker-compose.yml is just the starting point. Production workloads need healthchecks, profiles, override files,...

📅 30.06.2026 👁️ 79
📝

Docker Networking: How Containers Communicate

Containers are isolated, but they often need to talk to each other and to the...

📅 30.06.2026 👁️ 82

Did you like the article?

Subscribe to our updates and receive new articles first. Grow with PyLand!