Hands-on Docker (part 2): Save space with multi-stage builds

Build smaller Docker images by keeping build tools and source code out of the final image.
Published

October 31, 2026

Multi-stage builds keep build tools and source code out of the image you distribute. For this counter API, the final image was 99.64% smaller in a local build.

A mental model to create a multi-stage build

Separate what you need to build the application from what you need to run it:

flowchart LR
    accTitle: A general mental model for multi-stage builds
    accDescr: The build stage prepares application artifacts from source code and build dependencies. Required artifacts are copied into the final stage alongside its runtime and production dependencies.
    subgraph build["01. Build stage"]
        direction TB
        inputs["Source code and<br/>build dependencies"]
        prepare["Build or package<br/>the application"]
        artifacts["Application artifacts"]
        inputs --> prepare --> artifacts
    end

    subgraph final["02. Final stage"]
        direction TB
        requirements["Runtime and production<br/>dependencies"]
        image["Runnable<br/>application image"]
        requirements --> image
    end

    build -->|"Copy required artifacts"| final

“Artifacts” can be compiled binaries, packaged source code, or static assets. The final stage still needs whatever runs or serves them. Docker’s multi-stage build guide explains how stages work.

Implementing a multi-stage build

Continue from Part 1 in the same project directory. Keep the application source, config.yaml, and .dockerignore unchanged. Only the Dockerfile changes.

Before replacing Part 1’s Dockerfile, build its single-stage image as a comparison:

docker build -t my-counter:single-stage .

Replace the existing Dockerfile with:

# Build the executable with Rust and Alpine's musl development files.
FROM rust:alpine AS builder
WORKDIR /app
COPY . .
RUN apk add --no-cache musl-dev
RUN cargo install --path .

# Start a new filesystem containing only the application we copy in.
FROM scratch
WORKDIR /app/bin
COPY --from=builder /usr/local/cargo/bin/rust-rocket-counter-api /app/bin/app
ENV ROCKET_ADDRESS=0.0.0.0
CMD ["./app"]

AS builder names the first stage. FROM scratch starts an empty filesystem for the final stage. COPY --from=builder transfers the executable into it, without the compiler, source, or build cache.

Build the final stage under a separate tag:

docker build -t my-counter:multi-stage .

Command to run the app is:

docker run -p 8000:8000 -v ./config.yaml:/app/config.yaml my-counter:multi-stage
Using config file: config.yaml
Rocket has launched from http://0.0.0.0:8000

The impact of a multi-stage build

List both image tags:

docker image ls my-counter

Here is the output from the local comparison:

IMAGE                     ID             DISK USAGE   CONTENT SIZE   EXTRA
my-counter:multi-stage    55f98349c5cf       5.41MB             0B
my-counter:single-stage   64494a528b90       1.48GB             0B

Both images contain the same executable. The size difference comes from leaving the compiler, source, and build files out of the final image reducing the image size by 99.64%.

Final remarks

Multi-stage builds do not require scratch. Choose a final base image that provides the runtime your application needs, then copy in the build output.

For a Python or Node.js service, keep its language runtime and production dependencies in the final image. Leave build-only tools and files in the builder.

Github repository related to this post

References

https://docs.docker.com/build/building/multi-stage/