Docker Multistage Builds
What this article covers
- What multistage builds are.
- How to separate build tools from your runtime image.
- Practical Dockerfile examples.
- Benefits for security and image size.
- Tips for production-ready images.
Introduction: Docker Multistage Builds
A Docker image should be as small as possible and free of unnecessary tools. Packing everything into a single image means shipping compilers, development libraries, and test files along with your application. Multistage builds solve this by separating the build process from the actual runtime environment. You compile your application in an image with all the tools you need, then copy only the required artifacts into a lean runtime image.
This article shows how multistage builds work and what to consider when implementing them.
Key terms
- Multistage build: A Dockerfile with multiple
FROMinstructions. - Build stage: The image where compilation and preparation happen.
- Runtime stage: The image that actually runs in production.
- Distroless: Container images with no shell or package manager.
- Alpine: A minimal Linux image.
- Scratch: An empty base image.
- Artifact: The output of a build.
- Layer: A single level within an image.
When multistage builds make sense
- Your application needs to be compiled.
- Build dependencies should not end up in the final image.
- You want to reduce image size.
- You need to minimize the attack surface.
- Multiple steps are involved, such as testing and building.
Simple example: Python
# Build stage
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt
COPY . .
# Runtime stage
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]
Simple example: Go
# Build stage
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o main .
# Runtime stage
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/main /main
CMD ["/main"]
This image has neither a shell nor a package manager.
Simple example: Node.js
# Build stage
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY package*.json ./
ENV NODE_ENV=production
RUN npm ci --only=production
CMD ["node", "dist/main.js"]
Alpine as a runtime
Alpine is small but musl-based. Some applications need additional libraries:
FROM alpine:latest
RUN apk add --no-cache libstdc++
COPY --from=builder /app/main /main
CMD ["/main"]
Distroless images
Distroless images contain only runtime libraries, no shell and no package manager. Google provides pre-built images:
gcr.io/distroless/static-debian12gcr.io/distroless/python3-debian12gcr.io/distroless/nodejs-debian12
Advantages:
- Minimal attack surface.
- Small images.
- No interactive shell.
Disadvantages:
- No
docker execfor debugging. - Files cannot be installed via
RUN.
Leverage the build cache
Order your commands to maximize caching:
- Copy dependency files.
- Install dependencies.
- Copy source code.
- Run the build.
Multiple build stages
FROM node:20 AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20 AS build
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-slim AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY package*.json ./
RUN npm ci --only=production
CMD ["node", "dist/main.js"]
Size comparison
| Approach | Image size |
|---|---|
| Simple build image | 1 GB+ |
| Multistage with slim | 200 MB |
| Multistage with distroless | 20 MB |
Security
- No build tools in the runtime image.
- No compilers or header files.
- No development dependencies.
- No
.gitor test directories. - Run as a non-root user.
- Use a read-only filesystem where possible.
Common pitfalls
- Incorrect paths: Misspelled stage name in
--from. - Missing dependencies: Runtime library not present in the runtime image.
- Missing shell: Distroless makes
docker execimpossible. - Forgotten files: Required assets not copied.
- Build cache: Copying source code too early, before the install step.
- Multi-arch: No
BUILDPLATFORMvariable specified.
Further reading
- BotServ.de Docker image optimization
- BotServ.de Docker commands
- BotServ.de Docker security
- BotServ.de Docker rootless
FAQ: Multistage builds
Do I need multistage builds? Recommended for compiled languages and when image size or security matter.
Are distroless images suitable for homelabs? Yes, but debugging becomes harder.
Can I use more than two stages? Yes, as many as you need.
What happens to intermediate stages? They remain available until you clean or prune them.
Are Alpine and distroless the same? No. Alpine is small but includes a shell and package manager. Distroless is even more minimal.
Sources and further reading
- Docker Multistage: https://docs.docker.com/build/building/multi-stage/
- Distroless: https://github.com/GoogleContainerTools/distroless
- Alpine: https://hub.docker.com/_/alpine
Summary: Docker Multistage Builds
Multistage builds separate your build and runtime environments, producing smaller and more secure Docker images. For compiled languages, they are nearly essential. Using distroless or Alpine runtime images further reduces the attack surface. Clean paths, complete runtime libraries, and occasional checks of final image size are all important. By adopting multistage builds, you improve both build performance and operational efficiency.


