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 #

SetupImage Size
rust:latest (single stage, no optimization)1.2-1.6 GB
rust:latest + slim runtime150-250 MB
rust:alpine + multi-stage + alpine40-80 MB
Multi-stage + distroless cc15-40 MB
Static binary (musl) + scratch5-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, alpine with 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. glibc is built in.
  • ca-certificates — for HTTPS. Without it, reqwest calls 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 :nonroot tag already includes the nonroot user.
  • 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 — the USER directive doesn’t work directly. You need to add a passwd file manually or accept the default.
  • No DNS resolvergetaddrinfo will fail unless the binary links with built-in getaddrinfo or uses a static DNS crate.
  • No timezone datachrono with Local::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-tls is 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.

# 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.lock unchanged = 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 #

# 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 #

ConditionChoiceReason
Standard production servicedistroless/ccSmall size, solid observability
Lightweight microservice / CLIscratch + staticThe smallest size
Apps with many native depsdistroless/ccNeeds glibc
Sidecars / agentsscratch + staticCold-start time, size
Serverless (Lambda containers)scratch + staticCold-start time is critical
Apps without HTTPSscratch + staticNo 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:slim runtime), 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-musl enables a scratch runtime.
  • rustls-tls instead of native-tls — pure Rust TLS, no OpenSSL dependency. Fits static binaries and the slimmest images.
  • Strip binariesstrip = "symbols" in Cargo.toml or the strip command. Reduces size by 20-30%.
  • LTO + panic = "abort" + opt-level = "z" for the smallest binaries. Trade-off: longer builds.
  • Caching with cargo-chefcargo chef prepare + cargo chef cook is the modern pattern for caching Rust dependencies. More reliable than the dummy-main trick.
  • Distroless as the production defaultgcr.io/distroless/cc-debian12:nonroot carries glibc + ca-certificates without a shell. 15-40 MB.
  • CA certificates are mandatory for HTTPSCOPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ so HTTPS calls don’t fail.
  • Explicit tags, not latestrust:1.79.0-bookworm. Build reproducibility matters.
  • Check the ldd output — make sure your built binary doesn’t pull unexpected native dependencies. not a dynamic executable means 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.

← Previous: Ruby   Next: Best Practice →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact