Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 061 / 100

Dockerizing Rust apps: a practical guide for developers

Small, fast container images.

Expona Admin6 min read

Why Container Size Matters for Rust Applications

Rust binaries are notoriously large, and when you containerize them without optimization, you end up shipping megabytes of build artifacts, compiler toolchains, and unused dependencies to your registry and every deployment environment. Dockerizing Rust apps: a practical guide for developers means understanding that a naive docker build can easily produce images of 1–2 GB, turning what should be a lean, fast deployment into a bandwidth-heavy liability. We've all felt the pain of waiting five minutes for a 800 MB image to push to a registry before a deploy; the right approach cuts that to 50–100 MB and keeps your pipeline snappy.

Multi-Stage Builds: Compile Once, Ship Minimally

The cornerstone of efficient Rust Docker images is the multi-stage build. The idea is straightforward: use a heavy compiler image to build your release binary, then copy only the compiled artifact into a lightweight runtime image. The first stage includes the Rust toolchain, dependencies, and all the machinery needed for compilation; the second stage strips away everything except what your application actually needs to run.

Here's a production-tested pattern we use across our services:

rust
FROM rust:1.82 AS build
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release

FROM gcr.io/distroless/cc
COPY --from=build /app/target/release/api /api
ENTRYPOINT ["/api"]

This Dockerfile accomplishes several things at once. The build stage starts with the official Rust image, which includes rustc, cargo, and the standard library. We copy our source and configuration, then compile in release mode. The second stage pulls the distroless base image—a minimal image from Google that contains only the C runtime needed by Rust binaries—and copies the single compiled binary from the build stage.

The result is dramatic: a typical API service drops from 1.2 GB to around 60 MB. More importantly, none of the source code, build artifacts, or compiler toolchain ends up in the final image. Anyone pulling your container gets only the compiled application and its minimal runtime dependencies.

In a real microservices setup, this matters. We had a trading platform where each service rebuild and push took 3–4 minutes before optimization. Multi-stage builds cut that to under 30 seconds for many services because there's far less data to push and pull across the network.

Cache Dependencies with Cargo-Chef

One frustration with Docker and Rust is layer caching: docker build creates a new layer each time you change any file, and if your COPY . . happens before cargo build, then any change to source code invalidates the cache and forces a complete recompilation of all dependencies. For a project with dozens of crates, that recompilation can take 5–10 minutes.

The standard workaround is to copy Cargo.toml and Cargo.lock separately, create a dummy main.rs, and compile once. But there's a cleaner tool: cargo-chef. It builds a recipe of your dependencies, caches that layer, then builds your application in a separate layer. When you change source code, only the application rebuilds; when you add a dependency, the dependency layer rebuilds.

Here's how it works in practice:

rust
FROM rust:1.82 AS planner
RUN cargo install cargo-chef
WORKDIR /app
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM rust:1.82 AS cacher
RUN cargo install cargo-chef
WORKDIR /app
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json

FROM rust:1.82 AS builder
WORKDIR /app
COPY . .
COPY --from=cacher /app/target target
RUN cargo build --release

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

The planner stage reads your project and generates a recipe of dependencies. The cacher stage installs those dependencies without your source code. The builder stage copies the cached artifacts and builds your application. Now, if you only change your src/main.rs, Docker skips the cacher layer and rebuilds in seconds instead of minutes.

We've seen this reduce iteration time dramatically during development: developers can now rebuild container images locally in 15–20 seconds instead of waiting 10+ minutes, which means faster feedback loops and fewer context-switching delays.

Static Binaries with musl Target

Distroless images are great, but they still require glibc or libc to exist in the base layer. For maximum portability and smallest footprint, you can compile Rust to the x86_64-unknown-linux-musl target, which produces a statically linked binary with no external libc dependency. This lets you use scratch—Docker's truly empty base image.

Compiling for musl is straightforward:

rust
FROM rust:1.82 AS build
RUN rustup target add x86_64-unknown-linux-musl
WORKDIR /app
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl

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

A musl binary is entirely self-contained. The final image contains only your binary and nothing else—no libc, no shared libraries, no shell. This approach works beautifully for stateless services and gives you the absolute smallest possible image size, often under 20 MB for a simple service.

The tradeoff is that musl linking is sometimes slower and can occasionally cause compatibility issues with certain C libraries that assume glibc. For most Rust services using high-level crates, musl works perfectly. We use it for our Kafka consumers and internal CLI tools where size and portability are priorities.

Non-Root Users in Distroless Images

Security best practice dictates that container processes should not run as root. The distroless project provides nonroot variants that include a dedicated user for this purpose. Using a nonroot image is a simple switch:

rust
FROM rust:1.82 AS build
WORKDIR /app
COPY . .
RUN cargo build --release

FROM gcr.io/distroless/cc-nonroot
COPY --from=build /app/target/release/api /api
USER nonroot:nonroot
ENTRYPOINT ["/api"]

The cc-nonroot variant includes a nonroot user and group with UID 65532. By specifying USER nonroot:nonroot, you ensure the process runs without elevated privileges. This hardens the container: if an attacker gains code execution inside the container, they're confined to a non-privileged user with no ability to write to root-owned files or escalate privileges.

In production, this is non-negotiable. Every container we deploy runs as nonroot unless there's an explicit, documented reason not to.

Common Mistakes

Forgetting to set RUST_BACKTRACE during build: If your application panics in production and you want a stack trace, compile with RUN RUST_BACKTRACE=1 cargo build --release (though be aware this increases binary size slightly).

Not using --release for production images: Debug builds are 10× larger and 10× slower. Always use --release unless you're explicitly debugging.

Copying the entire source tree before dependencies: If your Dockerfile does COPY . . first, then any file change invalidates the build cache. Separate your Cargo files from your source when not using cargo-chef.

Mixing musl and glibc crates: Some Rust crates (especially those wrapping C libraries) expect glibc. If your build fails with musl, fall back to distroless/cc, which is still small and portable.

Ignoring the .dockerignore file: By default, Docker copies everything in your build context. Add a .dockerignore with target/, .git/, and node_modules/ (if applicable) to keep the context small and builds fast.

Key Takeaways

  • Multi-stage builds separate compilation from runtime, cutting image size from 1+ GB to 50–100 MB by using distroless base images.
  • Cargo-chef caches your dependencies as a Docker layer, so changes to source code don't trigger a full recompile of all dependencies.
  • Musl target and scratch images deliver the smallest possible containers (often under 20 MB) for stateless services with no external libc dependency.
  • Run containers as nonroot for security; distroless/cc-nonroot variants make this effortless.
  • Always use --release builds in production and keep your build context clean with .dockerignore.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles