How to Build Docker Images for Rust Applications

Create a multi-stage Dockerfile to compile Rust with rustup and ship a minimal runtime binary.

You've built the binary. Now ship it.

You've just finished your Rust CLI tool. It runs perfectly on your laptop. You push it to a server, and it crashes because libssl is missing. Or you build a Docker image, and it's 2 gigabytes because you accidentally packed the entire Rust toolchain, the standard library, and three weeks of cache into the container. You want a binary that runs anywhere and an image that deploys in seconds, not minutes.

Rust binaries are fast and safe, but shipping them requires care. A naive Dockerfile copies your source code, installs Rust, compiles the project, and runs the result. That image contains the compiler, the build cache, the standard library, and your binary. It's bloated, slow to pull, and exposes more attack surface than necessary.

Multi-stage builds solve this. They let you compile in a heavy environment and extract only the binary for a lightweight runtime. The result is a tiny image that contains exactly what the application needs to run and nothing else.

The bakery analogy

Multi-stage builds separate the build environment from the runtime environment. Think of it like a bakery. The kitchen has ovens, mixers, flour sacks, and a messy counter. That's your build stage. The customer gets a box with a single croissant. That's your runtime stage. You don't ship the oven with the pastry.

Rust multi-stage builds work the same way. The first stage installs the compiler and builds the binary. The second stage starts fresh with a minimal operating system and copies only the binary from the first stage. The final image has no knowledge of the compiler. It has no build cache. It has no source code. It has only the binary and the libraries it needs to execute.

This separation gives you two benefits. The image size drops from gigabytes to megabytes. The security surface shrinks because there are fewer tools and libraries for an attacker to exploit. You get the best of both worlds: a full-featured build environment and a minimal runtime.

Minimal multi-stage build

Here is the standard pattern for a Rust Dockerfile. It uses rust:1.90 for the builder and debian:bookworm-slim for the runtime.

# Build stage: heavy environment with compiler and source
FROM rust:1.90 AS builder
WORKDIR /app

# Copy dependency manifests first to leverage Docker layer caching
COPY Cargo.toml Cargo.lock ./
RUN cargo fetch

# Copy source code after dependencies are fetched
COPY . .
RUN cargo build --release

# Runtime stage: minimal environment for execution
FROM debian:bookworm-slim
WORKDIR /app

# Copy only the compiled binary from the builder stage
COPY --from=builder /app/target/release/my_binary /usr/local/bin/

# Run the binary directly without a shell
ENTRYPOINT ["my_binary"]

Replace my_binary with the name of your binary. You can find it in Cargo.toml under the [[bin]] section or in the package.name field. If you don't have a [[bin]] section, the binary name matches the package name.

How the layers work

The first FROM instruction starts the builder stage. Docker pulls the rust:1.90 image, which contains the Rust toolchain, the standard library, and a Debian base. You set the working directory to /app and copy Cargo.toml and Cargo.lock. Then you run cargo fetch.

cargo fetch downloads all dependencies and saves them to the build cache. This step is crucial for caching. Docker caches layers based on content. If you copy all source files before fetching dependencies, any change to a single file invalidates the cache for the dependency download step. By copying the manifest files first and running cargo fetch, you isolate the dependency layer. Docker reuses the cached dependencies as long as Cargo.lock doesn't change. This can cut build times from minutes to seconds.

After fetching dependencies, you copy the rest of the source code and run cargo build --release. The --release flag tells Cargo to optimize the code. It strips debug info and enables link-time optimization, making the binary faster and smaller. Without it, your binary runs slowly and takes up more space. Always build in release mode for production images.

The second FROM instruction starts a completely new image. It has no memory of the builder stage. It's a clean slate. You use COPY --from=builder to reach back into the previous stage and grab the compiled binary. The result is an image containing only the binary and the minimal OS libraries it needs.

The ENTRYPOINT instruction sets the command to run when the container starts. The JSON array form ["my_binary"] runs the binary directly. This is the exec form. It ensures the binary receives signals correctly. If you use the shell form ENTRYPOINT my_binary, Docker wraps the command in /bin/sh -c, which breaks signal handling. Rust processes expect signals to shut down gracefully. The shell swallows them. Use the exec form.

Convention: Cache the dependencies

The community standard for Rust Dockerfiles is to copy Cargo.toml and Cargo.lock before the source code. This pattern appears in almost every production Rust project. It's not just a trick. It's the only way to get reliable caching.

If you skip cargo fetch and go straight to cargo build, Docker still caches the dependency download, but the cache is tied to the build step. If you change a single line of code, Docker invalidates the build layer and re-downloads every crate. That's wasteful. cargo fetch creates a dedicated layer for dependencies. The build layer only contains the compilation. You get granular caching.

Another convention is to use rustup set profile minimal in the builder stage. The default Rust image installs a full profile with rustfmt, clippy, and other tools. You don't need those for building. The minimal profile installs only the compiler and standard library. It reduces the builder image size and speeds up the initial pull.

FROM rust:1.90 AS builder
WORKDIR /app
RUN rustup set profile minimal
COPY Cargo.toml Cargo.lock ./
RUN cargo fetch
COPY . .
RUN cargo build --release

This one line can shave hundreds of megabytes off the builder image. The builder is discarded after the build, so it doesn't affect the final artifact. But a smaller builder means faster CI pipelines. Every second counts.

Realistic: Distroless and musl

Production apps often need more than a slim image. They need security. distroless images contain no shell, no package manager, and no extra tools. Just the runtime libraries. If an attacker compromises your container, they have nowhere to go. There's no bash to run commands. There's no curl to exfiltrate data.

To use distroless, you need a binary that links against the same libraries as the image. distroless/cc-debian12 provides glibc and libstdc++. Your Rust binary links against glibc by default, so it works out of the box.

FROM rust:1.90 AS builder
WORKDIR /app
RUN rustup set profile minimal
COPY Cargo.toml Cargo.lock ./
RUN cargo fetch
COPY . .
RUN cargo build --release

FROM gcr.io/distroless/cc-debian12
COPY --from=builder /app/target/release/my_binary /
ENTRYPOINT ["/my_binary"]

The distroless image has no shell. You can't docker exec into it to debug. That's by design. If you need to debug, temporarily switch to debian:bookworm-slim or add a debug stage. Don't ship debug tools in production.

For maximum portability, you can build a static binary with musl. Static binaries contain all libraries inside the executable. They run on any Linux system, even ones without glibc. This is useful for scratch images, which are completely empty.

FROM rust:1.90 AS builder
WORKDIR /app
RUN rustup set profile minimal
# Install musl toolchain for static linking
RUN rustup target add x86_64-unknown-linux-musl
COPY Cargo.toml Cargo.lock ./
RUN cargo fetch
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl

FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/my_binary /
ENTRYPOINT ["/my_binary"]

The scratch image is the smallest possible image. It has no OS. It has no libraries. It has only your binary. The binary must be fully static. If it tries to load a shared library at runtime, it crashes. musl creates static binaries by default. glibc does not.

Static linking buys you portability, but test it. Some Rust crates use C libraries that don't support musl. They might panic at runtime or fail to build. Check your dependencies before committing to musl.

Pitfalls and compiler errors

If your crate depends on C libraries like OpenSSL, the build fails. The compiler rejects the build with a linking error. The linker can't find libssl.so. You must install the development packages in the builder stage.

FROM rust:1.90 AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y libssl-dev
COPY Cargo.toml Cargo.lock ./
RUN cargo fetch
COPY . .
RUN cargo build --release

The error message usually says linking with cc failed or cannot find -lssl. The fix is always the same: install the missing library in the builder. The runtime image doesn't need the development headers. It needs the shared libraries. debian:bookworm-slim includes most common libraries. distroless includes only the ones you link against. If you use distroless and link against a library it doesn't have, the container crashes at startup with error while loading shared libraries.

Another pitfall is layer cache invalidation. If you copy .git into the image, the cache changes every time you commit. Exclude .git and other non-essential files. Use a .dockerignore file.

.git
target
*.rs.bk

.dockerignore works like .gitignore. It tells Docker which files to skip. This keeps the build context small and prevents accidental leaks.

Decision: Choosing your runtime

Use debian:bookworm-slim when you need a balance of small size and debugging capability. It includes bash and basic tools, which helps when you need to inspect a running container.

Use gcr.io/distroless/cc-debian12 when security is the priority and you don't need a shell. The image contains only the binary and shared libraries, reducing the attack surface to almost nothing.

Use alpine:latest when you want a tiny image and your dependencies support musl libc. Some Rust crates struggle with Alpine's musl environment, leading to runtime panics. Test thoroughly before deploying.

Use scratch when your binary is fully static and you want the absolute smallest possible image. This requires building with --target x86_64-unknown-linux-musl and ensures no OS libraries are present.

Use cross when you need to build for a different architecture or musl target without installing toolchains locally. cross creates a Docker container with the target toolchain and runs the build inside it. It handles the complexity of cross-compilation automatically.

Don't ship the factory. Extract the binary and leave the compiler behind.

Where to go next