Guides · Deployment
Rust Dockerfile: a 10 MB image with fast rebuilds
A two-stage Dockerfile for a Rust web service, why cached dependency layers go wrong, and what to change for OpenSSL.
By Jonathan Pitter ·
The short answer
# syntax=docker/dockerfile:1
# 1. Build a static binary (Alpine's Rust targets musl)
FROM rust:1.99-alpine AS build
WORKDIR /src
COPY . .
# Cache the registry and target/ between builds. They are not part of any
# layer, so copy the binary out in the same RUN.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
cargo build --release --locked && cp target/release/web /web
# 2. Run it on an image with nothing else in it
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /web /web
EXPOSE 3000
ENTRYPOINT ["/web"]
Replace web with your package name from Cargo.toml. For an axum service with tokio, this image came out at 9.4 MB, running as an unprivileged user (uid 65532). The first build took 17 seconds; after editing main.rs, the rebuild took 4, because the compiled dependencies stay in the cache mount.
Why a static musl binary
rust:*-alpine images build for the musl C library, and Rust links musl statically by default. The result depends on nothing in the runtime image, so it runs on distroless static, which has CA certificates and a non-root user and no shell, package manager or libc.
The alternative is building on rust:1.99 (Debian, glibc) and running on gcr.io/distroless/cc-debian13 or debian:trixie-slim. Choose that when a dependency only works with glibc, or when musl's slower memory allocator shows up in your profiles (switching to mimalloc or jemalloc also fixes that).
Fast rebuilds with cache mounts
Rust compiles every dependency from source, so a build that starts from nothing takes minutes for a real project. Docker's layer cache alone doesn't help, because COPY . . changes on every commit. A BuildKit cache mount keeps target/ and the cargo registry between builds without putting them in a layer, so cargo recompiles only your crate.
The catch is that a cache mount only exists during its RUN. The binary has to be copied out of target/ in the same step, which is what && cp target/release/web /web does.
The placeholder main.rs trap
Without BuildKit, the usual trick is to build the dependencies against a placeholder main.rs first:
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src
COPY . .
RUN cargo build --release
This has a trap that is easy to miss. COPY keeps your files' modification times, and those are older than the placeholder build that just ran. Cargo decides what to rebuild by comparing times, sees nothing newer than its last build, and keeps the placeholder binary. The image builds, the container starts and exits immediately with no output, because it's running fn main() {}. We reproduced this: the shipped binary was 554 KB, against 2 MB for the real one.
Touch the sources before the second build:
COPY . .
RUN find src -name '*.rs' -exec touch {} + && cargo build --release
cargo-chef solves the same problem more thoroughly, for workspaces and multiple binaries included.
Listen on PORT and 0.0.0.0
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into());
let app = Router::new().route("/", get(|| async { "hello from axum\n" }));
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Binding to 127.0.0.1 only accepts connections from inside the container, so the service starts and nothing reaches it. Actix is the same: HttpServer::new(...).bind(("0.0.0.0", port)).
OpenSSL
The static build above works with crates that use rustls, which reqwest, sqlx and tokio-postgres can all be set to (default-features = false with their rustls feature). A crate that links OpenSSL (native-tls, openssl-sys) needs openssl-dev and openssl-libs-static in the build stage, and you lose the "nothing in the runtime image" property if it links dynamically. Prefer rustls in containers.
Add a .dockerignore
target
.git
.env*
Dockerfile
.dockerignore
target/ can be gigabytes, and sending it to the build is the most common reason a Rust Docker build sits at "transferring context".
Common problems
The container exits immediately with no output
You're running the placeholder binary. See the trap above.
feature edition2024 is required
The Rust in the image is older than 1.85, and Cargo.toml says edition = "2024", which cargo new writes. Use a newer image.
error: the lock file needs to be updated but --locked was passed
Cargo.lock is out of date or not committed. For a binary, commit it, so production builds the dependency versions you tested.
No such file or directory, for a binary that exists
It's dynamically linked against a libc the runtime image doesn't have, for example a glibc build copied onto Alpine or distroless static. Build on Alpine for static, or use distroless/cc for a glibc build.
Rust on Staxa
If the repository has no Dockerfile, Staxa detects Rust from Cargo.toml, reads the binary name from its [package] section, and generates a Dockerfile with the placeholder build and the fix above, since it can't rely on BuildKit cache mounts. For an axum service on port 3000 with Rust 1.99:
FROM rust:1.99-alpine AS builder
WORKDIR /app
RUN apk add --no-cache musl-dev pkgconfig openssl-dev
COPY Cargo.toml Cargo.lock ./
# Builds the dependencies alone, so a source change reuses this layer.
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src
COPY . .
# The copied sources are older than the placeholder build above, so cargo
# would keep its binary; touching them makes it rebuild the real one.
RUN find src -name '*.rs' -exec touch {} + && cargo build --release
FROM alpine:3.22
RUN apk add --no-cache ca-certificates libgcc
WORKDIR /app
COPY --from=builder /app/target/release/web ./app
RUN adduser -D -u 1001 app
USER app
ENV PORT=3000
EXPOSE 3000
CMD ["./app"]
It expects a single binary crate with src/main.rs and a committed Cargo.lock; a workspace needs its own Dockerfile. PORT is set to the service's port, and https://<service>-<tenant>.staxa.app routes to it with TLS. The axum and Actix Web PostgreSQL demos are complete apps, each with its own Dockerfile.
Frequently asked questions
scratch or distroless?
Distroless static is scratch plus CA certificates, time zone data and a non-root user. Without those, outbound HTTPS fails and the app runs as root, so distroless is the better default.
Can I cross-compile for ARM?
Yes: docker buildx build --platform linux/arm64,linux/amd64. Building an ARM image on an x86 machine under emulation is slow for Rust; building natively on each architecture, or cross-compiling with cargo zigbuild, is much faster.
How do I add a health check?
Add a route such as /healthz that returns 200, and point your platform's readiness check at it. Distroless has no shell or curl, so Docker's HEALTHCHECK instruction can't run there.