How to Use Multi-Stage Docker Builds for Rust

Use a multi-stage Dockerfile to compile Rust in a builder stage and copy the binary to a minimal final stage for smaller images.

The 2-gigabyte problem

You compile a Rust service. It works locally. You wrap it in a Dockerfile, run docker build, and the image hits 2 gigabytes. Pushing to the registry takes minutes. Your CI/CD pipeline slows down. Your security scanner flags hundreds of vulnerabilities inside the image, even though your application is just a few kilobytes of logic. The problem is not your code. The problem is the image carries the entire Rust toolchain, the standard library sources, build scripts, and every dependency used during compilation. Multi-stage builds solve this by separating the build environment from the runtime environment.

The bakery analogy

Think of a bakery. To bake a cake, you need industrial ovens, mixers, flour, sugar, eggs, and a messy kitchen. The kitchen is full of tools and ingredients. The customer walks in and wants a slice of cake. You do not hand them the oven. You do not hand them the flour sacks. You hand them the cake. Multi-stage Docker builds work the same way. The first stage is the kitchen. It has everything needed to build the binary. The second stage is the plate. It holds only the finished binary and the minimal runtime libraries required to execute it. The kitchen stays behind. Only the plate ships.

Minimal multi-stage build

A multi-stage Dockerfile uses multiple FROM instructions. Each FROM starts a new build stage. You can copy artifacts from earlier stages into later stages using COPY --from=stage_name.

# Stage 1: The builder. Contains the compiler and dependencies.
# This image is large but only exists during the build process.
FROM rust:1.80-bookworm AS builder
WORKDIR /app
# Copy the entire project into the container.
COPY . .
# Compile the release binary. This step takes time and disk space.
RUN cargo build --release

# Stage 2: The runtime. Minimal OS, no compiler.
# This image contains only the OS libraries needed to execute the binary.
FROM debian:bookworm-slim
# Copy only the compiled binary from the builder stage into this clean image.
COPY --from=builder /app/target/release/my_binary /usr/local/bin/
# Set the default command to run the binary.
CMD ["my_binary"]

Replace my_binary with the name of your binary. The name matches the package name in Cargo.toml unless you specified a different [[bin]] name. The builder stage compiles the code. The runtime stage copies the result. The final image is the runtime stage. It has no Rust compiler. It has no cargo. It has no source code.

The builder stage disappears after the build. Only the runtime stage ships.

Why the builder stage bloats your image

Rust compiles to native binaries. The target/ directory grows fast. It contains object files, debug information, intermediate artifacts, and the compiled standard library. A full target/ directory can exceed 10 gigabytes. If you copy target/ into the image, you pay for all that bloat. Multi-stage builds prevent this by discarding the target/ directory after the binary is extracted.

The rust:1.80-bookworm base image includes the Rust toolchain. The toolchain includes rustc, cargo, rustfmt, clippy, and the standard library sources. These tools are essential for compilation. They are useless at runtime. Including them increases the image size and expands the attack surface. Security scanners report vulnerabilities in the compiler and build tools. These vulnerabilities are irrelevant to your running application, but they clutter reports and fail compliance checks. Multi-stage builds remove the build tools from the final artifact, cleaning up the security posture.

Convention: Commit Cargo.lock to version control. Docker builds should be reproducible. Cargo.lock pins dependency versions. Without it, the build might pull different versions over time, leading to non-deterministic images.

The caching trap

Docker caches layers to speed up builds. Each instruction in the Dockerfile creates a layer. If the input to a layer has not changed, Docker reuses the cached layer. Rust builds are slow. Invalidating the cache on every keystroke kills productivity.

A common mistake is copying the entire source tree before building.

# BAD: This breaks caching.
COPY . .
RUN cargo build --release

If you change a single character in a source file, the COPY . . layer changes. Docker invalidates the cache for that layer and all subsequent layers. The build runs from scratch. Dependencies are re-downloaded and recompiled. This wastes time and CI credits.

The correct pattern separates dependency resolution from source compilation. Copy the manifest files first. Run cargo fetch to download dependencies. Copy the source code last. This way, dependency downloads are cached. Only source changes trigger recompilation.

Convention: Use cargo fetch instead of cargo build in the cache layer. cargo fetch downloads dependencies without compiling them. It is faster and creates a stable cache layer. Some teams use a dummy build to populate the cache, but cargo fetch is sufficient for most projects.

Realistic build with caching and security

A production-ready Dockerfile includes caching, security hardening, and runtime dependencies. The example below demonstrates the standard pattern.

# Stage 1: Builder with dependency caching.
FROM rust:1.80-bookworm AS builder
WORKDIR /app
# Copy manifests first to leverage Docker layer caching.
# If only source changes, dependencies are reused from cache.
COPY Cargo.toml Cargo.lock ./
# Fetch dependencies to populate the cache layer.
# This separates dependency resolution from source compilation.
RUN cargo fetch
# Copy source code after manifests.
# Changes here invalidate the build layer but not the dependency layer.
COPY . .
# Compile the release binary.
RUN cargo build --release

# Stage 2: Runtime with minimal dependencies.
FROM debian:bookworm-slim
# Install ca-certificates if the app makes HTTPS requests.
# Rust binaries often need certificates for TLS verification.
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
# Copy the binary from the builder stage.
COPY --from=builder /app/target/release/my_binary /usr/local/bin/
# Create a non-root user for security.
# Running as root is dangerous if the container is compromised.
RUN useradd --create-home appuser
USER appuser
CMD ["my_binary"]

The ca-certificates package is crucial for applications that make HTTPS requests. Rust's TLS libraries rely on system certificates. Without them, connections fail with certificate verification errors. The debian:bookworm-slim base includes glibc, which matches the builder's C library. This prevents dynamic linking issues.

Convention: Run as a non-root user. Add USER appuser to the runtime stage. This follows the principle of least privilege. If the container is compromised, the attacker has limited permissions. Many orchestration platforms enforce non-root execution.

Cache your dependencies. Rebuilding from scratch every time is a waste of time and CI credits.

Pitfalls: linking, libraries, and names

Dynamic linking causes the most common failures. Rust binaries link dynamically to libc by default. The binary expects libc at runtime. If the runtime image lacks the correct libc version, the binary fails to start. The error message is misleading.

error while loading shared libraries: libc.so.6: cannot open shared object file

Or simply:

No such file or directory

This happens when the runtime image uses a different C library than the builder. For example, building on rust:bookworm (which uses glibc) and running on alpine (which uses musl) breaks the binary. musl and glibc are not compatible. The binary expects glibc symbols but finds musl.

Convention: Use debian-slim for the runtime stage unless you have a specific reason to shrink further. alpine saves a few megabytes but introduces compatibility risks. debian-slim is safer for most applications. If you need alpine, build the binary on an alpine base or use static linking.

Static linking eliminates dynamic dependencies. Compile with RUSTFLAGS="-C target-feature=+crt-static" to produce a static binary. The binary includes all libraries. You can use FROM scratch in the final stage. scratch is an empty image. It has no OS. It has no libraries. The binary runs in isolation.

FROM rust:1.80-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN cargo fetch
COPY . .
# Static linking requires RUSTFLAGS.
RUN RUSTFLAGS="-C target-feature=+crt-static" cargo build --release

FROM scratch
# Copy binary and certificates.
# DNS resolution may fail without libc helpers.
COPY --from=builder /app/target/release/my_binary /
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/my_binary"]

Static binaries are larger than dynamically linked binaries. They include the standard library and all dependencies. DNS resolution can fail because scratch lacks libc helpers. You must copy certificates manually. Use static linking only when you need the absolute smallest attack surface or when the deployment environment lacks a standard C library.

Another pitfall is the binary name. If your Cargo.toml defines a different name, the path changes. Check target/release/ to find the actual binary name. The name matches the package name unless you specified [[bin]] with a name field.

Check your runtime image. If the binary needs a library, the runtime must provide it.

Decision matrix

Use multi-stage builds when you want to reduce image size and attack surface. The builder stage contains the compiler and build tools, which are unnecessary at runtime and increase vulnerability risk.

Use multi-stage builds when you need to separate build dependencies from runtime dependencies. Some crates require system libraries during compilation that are not needed when the binary runs.

Use a single-stage build when you are prototyping and image size does not matter. Skipping stages simplifies the Dockerfile and speeds up local iteration, but produces bloated images.

Use static linking with scratch when you need the absolute smallest image and your binary has no dynamic dependencies. Compile with RUSTFLAGS="-C target-feature=+crt-static" and use FROM scratch in the final stage. This eliminates the OS entirely, but requires careful handling of certificates and DNS resolution.

Pick the strategy that matches your deployment constraints. Size matters in production.

Where to go next