Containerizing a Rust Service, A Sub-25MB Production Image
TL;DR — Three stages: cargo-chef plan, cargo-chef cook deps (cached), build app. Static-link via musl to land in
distroless/static. Final image: ~22 MB for an Axum service. CI build time on warm cache: ~40 seconds.
After Axum + sqlx + serde + tracing, the service compiles and runs. Last piece for ship-readiness: container packaging. The Rust-specific challenge is that naive Dockerfiles produce 1.5 GB images and take 8 minutes to build on every commit.
The right pattern is cargo-chef for dep caching plus a musl static build into distroless/static. Same shape as the Go Dockerfile but with Rust-specific layer caching.
The naive Dockerfile (what NOT to do)
FROM rust:1.59
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/billing"]
Image: ~1.6 GB. Build: 6 minutes every commit because COPY . . invalidates the dependency layer on any source change. The world’s worst caching pattern in one file.
The production Dockerfile
# syntax=docker/dockerfile:1.4
# ---------- planner ----------
FROM rust:1.59-bullseye AS planner
WORKDIR /app
RUN cargo install cargo-chef --version 0.1.34 --locked
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# ---------- deps cache ----------
FROM rust:1.59-bullseye AS deps
WORKDIR /app
RUN cargo install cargo-chef --version 0.1.34 --locked
RUN apt-get update && apt-get install -y musl-tools && \
rustup target add x86_64-unknown-linux-musl
COPY --from=planner /app/recipe.json recipe.json
ENV PKG_CONFIG_ALLOW_CROSS=1
RUN cargo chef cook --release --target x86_64-unknown-linux-musl --recipe-path recipe.json
# ---------- build ----------
FROM deps AS builder
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl --bin billing
# ---------- runtime ----------
FROM gcr.io/distroless/static-debian11:nonroot
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/billing /billing
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/billing"]
Four stages. Let me walk through what each does.
Stage 1 — planner
cargo chef prepare walks the source and produces a recipe.json file that lists every dependency. It does not compile anything. The recipe is the input to the next stage’s deps build.
We have to copy the full source here (because the planner reads all Cargo.toml files) but we don’t compile anything, so this stage runs in seconds.
Stage 2 — deps cache
The big trick. cargo chef cook uses the recipe to compile only the dependencies, producing a layer that contains all crate compilations but none of your application code.
This layer is invalidated only when recipe.json changes — which only happens when you change Cargo.toml or Cargo.lock. Source code changes don’t touch it. With BuildKit cache-from, this layer reloads from registry in seconds, and the dep compile (which is what takes 90% of build time) is skipped entirely.
Without cargo-chef, every source change recompiles all dependencies. With it, dependencies compile once per dependency change, not once per source change.
musl-tools + the musl target give us a static binary. PKG_CONFIG_ALLOW_CROSS=1 is for crates that depend on system libraries via pkg-config.
Stage 3 — build
Inherits from deps, copies actual source, builds only the application binary. Because deps are already in the inherited layer cache, this stage compiles just billing + internal/billing/*. Takes seconds, not minutes.
Stage 4 — runtime
Distroless static. ~2 MB base. Copy the binary. Set non-root user. Done.
The musl static binary doesn’t need libc at runtime. Distroless static doesn’t have libc anyway. They go together.
What we ship
For a real Axum + sqlx + tracing service:
| Component | Size |
|---|---|
| Static binary (musl, release, stripped) | ~20 MB |
| distroless/static base | ~2 MB |
| Total image | ~22 MB |
CI build times (with mode=max BuildKit cache pushed to registry):
| Scenario | Time |
|---|---|
| Cold (no cache) | ~7 min |
| Warm (deps unchanged) | ~40 sec |
Warm (Cargo.lock changed) |
~5 min |
The 40-second warm rebuild is the number that matters. That’s what 95% of PRs experience.
CI integration (GitHub Actions)
- uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v3
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}/billing:${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}/billing:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}/billing:buildcache,mode=max
platforms: linux/amd64
Same pattern as the Go Docker post. Registry-backed buildcache keeps the cargo-chef layers warm across CI runs.
For multi-arch (linux/arm64 + linux/amd64), add aarch64-unknown-linux-musl to the rustup targets and a second cargo build invocation with that target. Costs more time but produces multi-arch images.
sqlx offline mode in CI
A specific gotcha: sqlx’s query! macro needs a database at compile time. In CI, set SQLX_OFFLINE=true and commit sqlx-data.json. See the sqlx post.
In the Dockerfile, set the env var in the build stage:
FROM deps AS builder
ENV SQLX_OFFLINE=true
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl --bin billing
Common Pitfalls
Skipping cargo-chef. The default Rust + Docker experience is painful without it. With it, you get Go-comparable CI times.
Building with glibc and trying to use distroless/static. Won’t work — the binary needs libc at runtime. Either build statically (musl) into distroless/static, or accept a distroless/cc base (~5 MB bigger) that does ship libc.
Forgetting --bin billing for workspaces. cargo build --release in a workspace builds every binary. For one-service-per-image, scope with --bin.
COPY . . in the deps stage. Defeats the whole point of cargo-chef. The planner stage copies everything (it just reads, doesn’t compile); the deps stage copies only the recipe; the build stage copies the rest.
Static linking with native dependencies. Some crates (e.g., openssl-sys) require careful build flags for musl. Switch to rustls (runtime-tokio-rustls feature on sqlx, rustls-tls on reqwest) to avoid OpenSSL entirely.
Logging stripped panic stack traces. cargo build --release strips symbols, which makes panic backtraces less useful. For production, accept the smaller binary and use distributed tracing for diagnostics; for staging, build without strip.
Wrapping Up
cargo-chef plus a musl + distroless build gets you to ~22 MB Rust container images that build in under a minute on warm cache. Same operational profile as our Go services. The ecosystem isn’t as battle-tested as Go’s container tooling but it’s now in “boring and works” territory. Monday: Rust vs Go for backend APIs — honest comparison after a month of writing both.