Rust #
Rust is often positioned as the “most ideal language” for containers because it can produce a single static binary. A single binary means no separate runtime, no dependency tree, no native modules to compile. Rust images can theoretically be as small as 5-15 MB.
But the reality is that Rust in production still has traps:
- Images bloat due to the wrong base image (glibc, musl).
- Long build times (especially with many dependencies).
- TLS and libc dependencies often go unnoticed.
- Debugging and observability are often neglected in favor of small size.
- Static binaries aren’t always the default — they need deliberate configuration.
This article discusses small, secure, truly production-grade Rust Docker image strategies, not just a FROM scratch demo.
1. The Reality of Rust Image Sizes #
| Setup | Image Size |
|---|---|
rust:latest (single stage, no optimization) | 1.2-1.6 GB |
rust:latest + slim runtime | 150-250 MB |
rust:alpine + multi-stage + alpine | 40-80 MB |
| Multi-stage + distroless cc | 15-40 MB |
| Static binary (musl) + scratch | 5-15 MB |
Insight: The difference between 1.6 GB and 5 MB is hundreds of times. A Rust image without a proper strategy is an extraordinary waste, because Rust is actually very capable of producing extremely small images.
2. Why Rust Images Can Still Be Large #
The Build Toolchain Is Very Large #
The Rust toolchain (rustc, cargo, LLVM, linker) itself is hundreds of MB and never needed at runtime. Without multi-stage, all these tools enter the runtime image.
Binaries Are Dynamic by Default #
By default, Rust links binaries to glibc on Linux. Binaries depending on glibc:
- Need a base image with glibc (
debian,ubuntu,alpinewith gcompat,distroless/cc). - Can’t use
scratch(empty, no libc). - Have a minimum image size of ~50 MB just for the runtime.
TLS and libc Are Silent Dependencies #
Crates like reqwest, openssl, native-tls pull runtime dependencies that aren’t directly visible from the code. For example, reqwest with native-tls needs OpenSSL in the runtime image.
Crates that pull native dependencies:
reqwest(default) +native-tls→ needs OpenSSL.reqwest+rustls→ pure Rust TLS, no native dependency.openssl→ needs OpenSSL at runtime.ring→ usually pure Rust, but check.libsqlite3-sys→ needs SQLite.
How to check native dependencies:
# Build the binary, then check dynamic libraries
ldd target/release/myapp
Output to watch for:
libssl.so→ needs OpenSSL.libcrypto.so→ needs OpenSSL.libc.so.6→ needs glibc.libm.so.6→ needs glibc.not a dynamic executable→ static binary, needs no libc at all.
3. The Main Principle: Binary + Certs, Full Stop #
An ideal Rust runtime image contains only: the application binary + CA certificates (for HTTPS).
Full stop. No compilers, no linkers, no source code, no build cache, no cargo, no rustc.
4. Multi-Stage Build Strategies #
4.1 The Basic Pattern: Debian Slim Runtime #
# syntax=docker/dockerfile:1.7
# ==== Stage 1: Build ====
FROM rust:1.79-bookworm AS builder
WORKDIR /app
# Install build dependencies (if any)
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Cache dependencies
COPY Cargo.toml Cargo.lock ./
RUN mkdir src \
&& echo "fn main() { println!(\"placeholder\"); }" > src/main.rs \
&& cargo build --release \
&& rm -rf src target/release/deps/myapp*
# Build the application
COPY src ./src
COPY tests ./tests
RUN cargo build --release --locked
# Strip the binary
RUN strip target/release/myapp
# ==== Stage 2: Runtime ====
FROM debian:bookworm-slim
WORKDIR /app
# Install CA certificates for HTTPS
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy the binary
COPY --from=builder /app/target/release/myapp /usr/local/bin/myapp
# Non-root user
RUN groupadd -r app && useradd -r -g app -d /app -s /bin/bash app
USER app
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/myapp"]
Typical size: 40-80 MB.
Key explanations:
Stage 1: Build
rust:1.79-bookworm— the official Rust image with the full toolchain.pkg-config,libssl-dev— for native dependencies (OpenSSL).- The cache trick: create a dummy
main.rs, build to cache dependencies, remove it, then build for real. This maximizes Docker layer caching — code changes don’t invalidate the dependency download. cargo build --release --locked— release-mode build (optimized) with the lock file.strip target/release/myapp— removes debug symbols, reducing size by 20-30%.
Stage 2: Runtime
debian:bookworm-slim— minimal Debian.glibcis built in.ca-certificates— for HTTPS. Without it,reqwestcalls to HTTPS will fail.- Non-root user for security.
When to use: The default for production Rust services. The size vs debugging capability trade-off is balanced.
4.2 Distroless Runtime — Mature Production #
Distroless carries glibc, CA certs, and tzdata, without a shell:
# ==== Stage 1: Build ====
FROM rust:1.79-bookworm AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
RUN mkdir src \
&& echo "fn main() { println!(\"placeholder\"); }" > src/main.rs \
&& cargo build --release \
&& rm -rf src target/release/deps/myapp*
COPY src ./src
RUN cargo build --release --locked \
&& strip target/release/myapp
# ==== Stage 2: Distroless Runtime ====
FROM gcr.io/distroless/cc-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/target/release/myapp /app/myapp
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/myapp"]
Typical size: 15-40 MB.
Important notes:
gcr.io/distroless/cc-debian12:nonroot— distroless with glibc + libgcc + ca-certificates + tzdata. Fits binaries linked against glibc.- The
:nonroottag already includes thenonrootuser. - No shell — interactive debugging isn’t possible.
- A Dockerfile HEALTHCHECK is difficult — move it to the orchestrator.
4.3 Static Binary with musl + scratch — The Smallest #
For the smallest images, build a fully static binary and use scratch (empty).
Build with the musl target:
# ==== Stage 1: Build ====
FROM rust:1.79-bookworm AS builder
WORKDIR /app
# Install the musl target
RUN rustup target add x86_64-unknown-linux-musl
# Install musl-tools for the linker
RUN apt-get update && apt-get install -y --no-install-recommends \
musl-tools \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# Cache dependencies
COPY Cargo.toml Cargo.lock ./
RUN mkdir src \
&& echo "fn main() { println!(\"placeholder\"); }" > src/main.rs \
&& cargo build --release --target x86_64-unknown-linux-musl \
&& rm -rf src target/x86_64-unknown-linux-musl/release/deps/myapp*
# Build
COPY src ./src
RUN cargo build --release --target x86_64-unknown-linux-musl --locked \
&& strip target/x86_64-unknown-linux-musl/release/myapp
# ==== Stage 2: Scratch Runtime ====
FROM scratch
# Copy the binary
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /app
# For HTTPS, copy CA certificates
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# No user, because scratch has no /etc/passwd
# The binary must run as the default (root) or we set up a user later
EXPOSE 8080
ENTRYPOINT ["/app"]
Typical size: 5-15 MB.
Important notes for scratch:
- No
/etc/passwd— theUSERdirective doesn’t work directly. You need to add a passwd file manually or accept the default. - No DNS resolver —
getaddrinfowill fail unless the binary links with built-ingetaddrinfoor uses a static DNS crate. - No timezone data —
chronowithLocal::now()will error. - No CA certs — already copied manually.
Solution for non-root on scratch:
# Minimal passwd setup
FROM scratch
# Import users from the builder
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
# Copy the binary
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 1001:1001
ENTRYPOINT ["/app"]
Solution for DNS on scratch:
Use a static crate like dns-lookup or hickory-resolver, or link to getaddrinfo from musl.
4.4 TLS with rustls (Pure Rust) #
A way to avoid native OpenSSL dependencies: use rustls in reqwest:
# Cargo.toml
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
rustls is a pure-Rust TLS implementation. No OpenSSL needed at runtime. Fits scratch and the most minimal images.
Notes:
rustls-tlsis smaller and more portable.- Not compatible with all OpenSSL features (e.g. FIPS).
- For enterprises needing OpenSSL/FIPS, keep using
native-tls.
5. Binary Size Optimizations #
5.1 Strip the Binary #
RUN strip target/release/myapp
Removes debug symbols and DWARF info. Reduces size by 20-30%.
5.2 strip = "symbols" in Cargo.toml
#
# Cargo.toml
[profile.release]
strip = "symbols"
Same effect as the strip command, but automatic on cargo build --release.
5.3 LTO (Link-Time Optimization) #
# Cargo.toml
[profile.release]
lto = true
LTO lets the compiler optimize the whole program at link time. The result: a smaller binary (5-15%) that can also be faster. Trade-off: build time increases significantly (can be 2-5x longer).
5.4 panic = "abort"
#
# Cargo.toml
[profile.release]
panic = "abort"
Removes unwinding code. Smaller binary, no need for libunwind. Trade-off: stack traces on panic become minimal.
5.5 Code Optimization (codegen-units) #
# Cargo.toml
[profile.release]
codegen-units = 1
One code generation unit allows more aggressive optimization. Smaller and faster binaries, but longer builds.
5.6 opt-level = "z" or "s"
#
# Cargo.toml
[profile.release]
opt-level = "z" # optimize for size
opt-level = "z" (size) or "s" (small) trades a little performance for a smaller binary. For many service workloads, this is a good trade-off.
5.7 The Optimal Combination #
# Cargo.toml
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = "symbols"
This combination produces the smallest binary that’s still fast enough for most services.
6. Caching Strategy #
The Rust dependency cache (cargo build for dependencies) is very effective for Docker layer caching:
# Step 1: Copy the manifest
COPY Cargo.toml Cargo.lock ./
# Step 2: Create a dummy main to trigger dependency compilation
RUN mkdir src \
&& echo "fn main() {}" > src/main.rs \
&& cargo build --release
# Step 3: Remove the dummy
RUN rm -rf src
# Step 4: Copy the real source
COPY src ./src
# Step 5: Build for real (dependencies already cached)
RUN cargo build --release
This pattern ensures:
Cargo.toml/Cargo.lockunchanged = the dependency layer is reused.- Source code changes = only the source rebuilds; dependencies are reused.
Alternative: cargo-chef
The cargo-chef tool automates the pattern above:
FROM lukemathwalker/cargo-chef:latest-rust-1.79-bookworm AS chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --locked
cargo-chef is more reliable and idiomatic for large projects.
7. Static vs Dynamic Linking #
When to Use Static (musl) #
Pros:
- Smallest images (5-15 MB).
- Can use
scratch. - No runtime dependencies.
Cons:
- Some native crates don’t support musl.
- TLS performance is slightly slower (rustls vs OpenSSL).
- Harder debugging (no standard libc).
When to Use Dynamic (glibc) #
Pros:
- All native crates usually supported.
- Better TLS performance (when using OpenSSL).
- Easier debugging (there’s libc, there are tools).
Cons:
- Larger images (15-40 MB with distroless).
- Needs a base image with glibc.
Recommendations:
- Static (musl) for CLI tools, agents, sidecars, simple microservices.
- Dynamic (glibc) for services with many native deps, or those needing high TLS performance.
8. Healthchecks, Logging, and Signals #
Healthchecks #
Create an HTTP endpoint in the Rust service (axum, actix-web, warp, etc.):
async fn health() -> &'static str {
"ok"
}
In the Dockerfile (for images with a shell):
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1
For distroless/scratch — move to orchestrator probes.
Logging #
Use a structured logger crate (tracing, slog, log + env_logger):
use tracing::{info, Level};
use tracing_subscriber::FmtSubscriber;
let subscriber = FmtSubscriber::builder()
.with_max_level(Level::INFO)
.json()
.with_writer(std::io::stdout)
.finish();
tracing::subscriber::set_global_default(subscriber).unwrap();
info!(path = "/health", status = 200, "request completed");
Principles:
- Log to STDOUT (or STDERR for errors).
- JSON format for log aggregators.
- Include trace IDs for distributed tracing.
Signal Handling #
Tokio, hyper, axum, and actix-web handle SIGTERM correctly by default, but make sure:
- Exec form in
ENTRYPOINT(ENTRYPOINT ["/app/myapp"]). - Graceful shutdown for in-flight requests:
use tokio::signal;
let shutdown = async {
signal::ctrl_c().await.expect("failed to install CTRL+C handler");
};
axum::Server::bind(&addr)
.serve(app.into_make_service())
.with_graceful_shutdown(shutdown)
.await
.unwrap();
9. Security Hardening #
Non-Root Users #
# For distroless
USER nonroot:nonroot
# For debian-slim
RUN groupadd -r app && useradd -r -g app -d /app -s /bin/bash app
USER app
# For scratch (see the non-root strategy above)
Vulnerability Scanning #
- name: Build
run: docker build -t myapp:${{ github.sha }} .
- name: Scan
run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}
Rust images are relatively small with few dependencies, but the base image (debian, distroless) still needs patching. Rebuild regularly.
Read-Only Filesystems #
Well-written Rust services usually work with a read-only root:
docker run --read-only --tmpfs /tmp myapp
10. Anti-Patterns to Avoid #
✗ Single-Stage Builds #
// ✗ Compilers, cargo, source code all in the final image
FROM rust:1.79
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/myapp"]
Solution: Multi-stage, slim runtime stage.
✗ Using rust:latest
#
// ✗ Non-deterministic builds
FROM rust:latest
Solution: Pin the tag: rust:1.79.0-bookworm.
✗ Building with the Default Target (glibc) #
// ✗ The binary isn't static; it must use a glibc base image
FROM rust:1.79-bookworm AS builder
RUN cargo build --release
// the binary now needs glibc
Solution: Build with the musl target (--target x86_64-unknown-linux-musl) for a static binary.
✗ Using Native OpenSSL #
# Cargo.toml
reqwest = "0.12" # default features include native-tls
Solution: Use rustls-tls for pure Rust TLS.
✗ Copying Source to Runtime #
// ✗ .rs source code sits in the runtime image
FROM debian:bookworm-slim
WORKDIR /app
COPY . .
COPY --from=builder /app/target/release/myapp /app
Solution: Only copy the binary from the build stage.
✗ Not Using a Lock File #
# ✗ Non-reproducible builds
cargo build --release
Solution: cargo build --release --locked with Cargo.lock committed.
11. Production-Grade Rust Dockerfile Examples #
11.1 The Distroless Version (Recommended) #
# syntax=docker/dockerfile:1.7
# ==== Stage 1: Build ====
FROM rust:1.79.0-bookworm AS chef
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/* \
&& cargo install --locked cargo-chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --locked \
&& strip target/release/myapp
# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/cc-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/target/release/myapp /app/myapp
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/myapp"]
11.2 The Static Binary + Scratch Version (Smallest) #
# syntax=docker/dockerfile:1.7
FROM rust:1.79.0-bookworm AS chef
RUN apt-get update && apt-get install -y --no-install-recommends \
musl-tools \
pkg-config \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add x86_64-unknown-linux-musl \
&& cargo install --locked cargo-chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json --target x86_64-unknown-linux-musl
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json --target x86_64-unknown-linux-musl
COPY . .
ENV CC_x86_64_unknown_linux_musl=musl-gcc
RUN cargo build --release --target x86_64-unknown-linux-musl --locked \
&& strip target/x86_64-unknown-linux-musl/release/myapp
# ==== Runtime: Scratch ====
FROM scratch
# Minimal passwd setup
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
# Copy the binary and CA certs
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /app/myapp
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 1001:1001
EXPOSE 8080
ENTRYPOINT ["/app/myapp"]
Characteristics:
- Size: 5-15 MB.
- Fully static binary.
- CA certs copied manually.
- Non-root user with an explicit UID.
12. When to Use Which Strategy #
| Condition | Choice | Reason |
|---|---|---|
| Standard production service | distroless/cc | Small size, solid observability |
| Lightweight microservice / CLI | scratch + static | The smallest size |
| Apps with many native deps | distroless/cc | Needs glibc |
| Sidecars / agents | scratch + static | Cold-start time, size |
| Serverless (Lambda containers) | scratch + static | Cold-start time is critical |
| Apps without HTTPS | scratch + static | No certs needed |
13. Rust Dockerfile Review Checklist #
BASE IMAGE:
□ Explicit tag (rust:1.79.0-bookworm, not latest)
□ Slim runtime stage (debian-slim, distroless, scratch)
□ Not rust:latest (too large)
BUILD:
□ Multi-stage build
□ cargo build --release --locked
□ strip the binary
□ Dependency caching (cargo-chef or dummy main)
□ Build tools only in the build stage
OPTIMIZATION:
□ lto = true (if build time is OK)
□ codegen-units = 1
□ panic = "abort"
□ strip = "symbols" or a strip command
□ opt-level = "z" (if size optimization is a priority)
LINKING:
□ musl target for static binaries
□ rustls-tls instead of native-tls (for pure Rust TLS)
□ Check ldd output — no glibc dependencies (for scratch)
RUNTIME:
□ USER nonroot
□ CA certificates copied
□ Logs to STDOUT
□ Healthcheck (if a shell exists)
□ Signal handling (graceful shutdown)
□ ENTRYPOINT in exec form
SIZE:
□ < 80 MB for debian-slim runtime
□ < 40 MB for distroless runtime
□ < 15 MB for scratch + static
□ docker history shows no odd layers
SECURITY:
□ No secrets in the image
□ Strict .dockerignore
□ Image scanned with trivy/grype
□ Non-root user
□ Base image up to date
TLS:
□ reqwest with rustls-tls (default features off)
□ CA certs copied to the runtime image
□ Custom TLS config tested
Summary #
- Slim Rust images are very possible — Rust is the most container-capable language, with a 5-15 MB potential. But it needs disciplined Dockerfiles.
- Size reality: 5-15 MB (scratch + static), 15-40 MB (distroless/cc), 40-80 MB (debian-slim), 150-250 MB (
rust:slimruntime), 1.2-1.6 GB (rust:latest— anti-pattern).- Multi-stage builds are mandatory — compilers, cargo, and LLVM must stop at the build stage. The runtime image only contains the binary + ca-certificates.
- Static binaries with musl for the smallest images —
--target x86_64-unknown-linux-muslenables ascratchruntime.rustls-tlsinstead ofnative-tls— pure Rust TLS, no OpenSSL dependency. Fits static binaries and the slimmest images.- Strip binaries —
strip = "symbols"in Cargo.toml or thestripcommand. Reduces size by 20-30%.- LTO +
panic = "abort"+opt-level = "z"for the smallest binaries. Trade-off: longer builds.- Caching with cargo-chef —
cargo chef prepare+cargo chef cookis the modern pattern for caching Rust dependencies. More reliable than the dummy-main trick.- Distroless as the production default —
gcr.io/distroless/cc-debian12:nonrootcarries glibc + ca-certificates without a shell. 15-40 MB.- CA certificates are mandatory for HTTPS —
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/so HTTPS calls don’t fail.- Explicit tags, not
latest—rust:1.79.0-bookworm. Build reproducibility matters.- Check the
lddoutput — make sure your built binary doesn’t pull unexpected native dependencies.not a dynamic executablemeans static.- Slim images need solid observability — JSON logs to STDOUT, metrics endpoints, healthchecks, graceful shutdown. Rust can be 5-15 MB without sacrificing operations.
- Scratch for CLI/agents/small microservices — larger production services usually fit distroless better for stability.