When your binary refuses to run
You spent three days building a REST API in Rust. It handles requests, talks to a database, and returns JSON faster than you can blink. You zip up the binary and send it to your deployment server. The server screams at you: error while loading shared libraries: libssl.so.3: cannot open shared object file. Or worse, the binary is 40 megabytes, and your hosting plan charges by the gigabyte. You need a way to package your code so it runs anywhere, without dragging your entire development environment along for the ride. Docker solves this by freezing your runtime dependencies into a single, portable image.
The container concept
Docker creates a container. Think of a container as a sealed lunchbox. Your code is the sandwich. The operating system libraries are the napkins and utensils. When you hand the lunchbox to someone, they don't need to know what's inside or have a kitchen. They just open it and eat.
In Rust, you often compile a static binary that contains everything. You could just run that binary anywhere. But if you use dynamic linking, or if you want to guarantee the exact version of libc or OpenSSL, Docker wraps the binary in a minimal operating system layer. The result is an image that runs identically on your laptop, a cloud server, or a Raspberry Pi.
Rust's ownership model keeps your memory safe. Docker's isolation keeps your deployment safe. The two complement each other. You get a binary that cannot segfault due to memory errors, packaged in an environment that cannot leak secrets to the host.
Minimal multi-stage build
The standard pattern for Rust in Docker is a multi-stage build. You use one stage to compile and a second stage to run. This keeps the final image small.
// src/main.rs
/// A minimal web server that returns a greeting.
fn main() {
println!("Server running on port 8080");
// In a real app, this would bind to a socket and handle requests.
// For this example, we just print to prove the binary works.
}
# Dockerfile
# Use the official Rust image for building.
# This contains the compiler, toolchain, and build dependencies.
FROM rust:1.75-slim as builder
# Set the working directory inside the container.
WORKDIR /app
# Copy dependency manifests first to leverage Docker layer caching.
# If you change code but not dependencies, this layer is reused.
COPY Cargo.toml Cargo.lock ./
# Create a dummy src/main.rs to build dependencies without copying source.
# This speeds up rebuilds when only application logic changes.
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
# Remove the dummy source so the real source can be copied cleanly.
RUN rm src/main.rs
# Copy the actual application source code.
COPY src ./src
# Build the release binary.
# --release enables optimizations and link-time optimization.
RUN cargo build --release
# Start a new stage from a minimal base image.
# This keeps the final image small by excluding the compiler.
FROM debian:bookworm-slim
# Copy the compiled binary from the builder stage.
COPY --from=builder /app/target/release/my-server /usr/local/bin/
# Run the binary.
CMD ["my-server"]
The Dockerfile describes two separate worlds. The first stage, labeled builder, spins up a heavy container with the Rust compiler installed. It downloads crates, compiles your code, and produces the binary. The second stage starts fresh with a tiny operating system. It grabs only the binary from the first stage and discards the compiler. When you run the container, you get the binary plus just enough OS to run it. No compiler. No source code. No build tools. Just the executable and its runtime dependencies.
Counter-intuitive but true: the smallest image often comes from the heaviest builder. The builder does the work so the runtime can stay lean.
How Docker caches work
Docker builds images layer by layer. Each instruction in the Dockerfile creates a layer. Docker hashes the input of each instruction. If the hash matches a previous build, Docker reuses the layer. This is why order matters.
If you write COPY . . before RUN cargo build, Docker hashes the entire directory. Change one file, the hash changes, the layer invalidates. Docker recompiles everything. If you copy Cargo.toml and Cargo.lock first, Docker hashes only the manifest. Dependencies unchanged, hash matches, layer reused. Docker skips the download and compilation of crates. It jumps straight to compiling your source code.
Convention aside: the community calls this the "manifest-first" pattern. It saves minutes on every rebuild. If your project has many dependencies, the time savings compound quickly.
Don't fight the layer cache. Copy manifests first.
Realistic production setup
A production Dockerfile adds security, better caching, and runtime dependencies. You also need to handle graceful shutdowns.
# Production-ready Dockerfile with optimized caching and security.
FROM rust:1.75-slim as builder
WORKDIR /app
# Copy manifests to cache dependencies.
COPY Cargo.toml Cargo.lock ./
# Pre-build dependencies to create a cache layer.
# This avoids recompiling crates when only source code changes.
RUN cargo init --name server .
RUN cargo build --release
RUN rm -rf src
# Copy source and build.
COPY src ./src
RUN cargo build --release
# Use a minimal base image.
# Debian slim is preferred over Alpine for Rust due to glibc compatibility.
FROM debian:bookworm-slim
# Install ca-certificates for HTTPS requests if your server calls external APIs.
# Many Rust crates need root certificates to verify TLS connections.
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user for security.
# Running as root inside a container is a security risk.
RUN addgroup --system --gid 10001 appgroup && \
adduser --system --uid 10001 appuser
# Copy the binary and set ownership.
COPY --from=builder /app/target/release/server /usr/local/bin/
RUN chown appuser:appgroup /usr/local/bin/server
# Switch to the non-root user.
USER appuser
# Expose the port for documentation.
# This does not publish the port; docker run -p does that.
EXPOSE 8080
# Run the binary.
CMD ["server"]
The USER instruction drops privileges. If an attacker exploits your server, they get access as appuser, not root. This limits the damage. The ca-certificates package fixes a common runtime error where TLS connections fail because the container lacks root certificates. The EXPOSE instruction is documentation. It tells readers which port the app listens on. It does not publish the port. You still need docker run -p 8080:8080 to map the port.
Convention aside: EXPOSE is a convention, not a mechanism. Docker ignores it unless you use docker run -P. Treat EXPOSE as a comment for humans.
Treat the USER instruction as a firewall. If you skip it, you're running as root.
Pitfalls and runtime errors
The Alpine trap
You might see tutorials using alpine:latest. Alpine uses musl libc instead of glibc. Rust's standard library and many crates assume glibc. If you compile on glibc and run on musl, or vice versa, you get runtime crashes. The error isn't a compiler error. It's a silent failure at startup: error while loading shared libraries. Stick to Debian-based images unless you explicitly cross-compile for musl.
Alpine looks smaller, but musl compatibility issues will cost you more time than the megabytes save.
Debug binaries
If you forget --release, Docker builds a debug binary. Debug binaries include debug symbols and lack optimizations. The image size balloons. The server runs slower. The compiler doesn't warn you. It just builds what you asked for. Always use --release for production.
Signal handling
Docker sends SIGTERM when you stop a container. Rust's main function needs to handle this gracefully. If you use a framework like Axum or Actix, they handle signals automatically. If you write raw sockets, you might need a signal handler. Without handling, the container hangs on shutdown. Docker waits for a timeout, then sends SIGKILL. Your server dies abruptly. Connections drop. Data might corrupt.
Trust the framework's signal handling. If you write raw sockets, add a handler.
Decision matrix
Use rust:1.75-slim as your builder when you want a balance of build speed and image size. The slim image includes the compiler without unnecessary development tools.
Use debian:bookworm-slim as your runtime base when your binary links against glibc. Most Rust binaries do. This avoids runtime library mismatches.
Use alpine as your runtime base only when you have cross-compiled your binary for musl explicitly. The smaller image size is worth the complexity only if you have verified the binary runs correctly on musl.
Use cargo-chef or the cargo init dummy pattern when your dependency tree is large and rebuilds are slow. These patterns cache compiled dependencies separately from your source code, reducing rebuild times from minutes to seconds.
Use docker buildx with --platform linux/amd64,linux/arm64 when you need to deploy to both x86 servers and ARM devices like Raspberry Pis. This builds multi-architecture images in a single command.
Use cross crate when you need to build for a target architecture that differs from your host machine. cross handles the toolchain setup and cross-compilation automatically.