Skip to content
BotServBotServ
DockerMultistageDockerfileImage optimization

Docker Multistage Builds

Build smaller, more secure Docker images with multistage builds. Build, runtime, and distroless images.

S

schutzgeist

3 min read
Docker Multistage Builds

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 FROM instructions.
  • 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-debian12
  • gcr.io/distroless/python3-debian12
  • gcr.io/distroless/nodejs-debian12

Advantages:

  • Minimal attack surface.
  • Small images.
  • No interactive shell.

Disadvantages:

  • No docker exec for debugging.
  • Files cannot be installed via RUN.

Leverage the build cache

Order your commands to maximize caching:

  1. Copy dependency files.
  2. Install dependencies.
  3. Copy source code.
  4. 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

ApproachImage size
Simple build image1 GB+
Multistage with slim200 MB
Multistage with distroless20 MB

Security

  • No build tools in the runtime image.
  • No compilers or header files.
  • No development dependencies.
  • No .git or 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 exec impossible.
  • Forgotten files: Required assets not copied.
  • Build cache: Copying source code too early, before the install step.
  • Multi-arch: No BUILDPLATFORM variable specified.

Further reading

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

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.

Back to Blog
Share:

Related Posts