Deploying Rust: Docker, Binaries, and Why Your Image Can Be Tiny
This is where Rust pays for itself in ways that are immediately visible. A Rust binary is statically compiled, has no runtime dependency, and doesn't need an interpreter, a VM, or a garbage collector running alongside it. A Docker image for a Rust service can be 10-50MB. Your Python service's image is probably 800MB and you've stopped thinking about it.
More importantly: the binary is the deployment artifact. There's no gem bundle to install, no pip packages to resolve, no node_modules to copy. You ship a file. It runs.
Building a Release Binary
# Debug build (fast to compile, slow to run, includes debug info)
cargo build
# Release build (slow to compile, fast to run, optimized)
cargo build --release
The release binary ends up at target/release/your-binary-name. It's self-contained. Copy it to any Linux machine with a compatible libc and run it.
# Check the binary size
ls -lh target/release/myapp
# Check what it links against
ldd target/release/myapp
The ldd output shows dynamic library dependencies. A typical Rust binary links against libgcc_s.so, libc.so.6, and libm.so.6 — the core system libraries. You can statically link even these with musl.
Static Linking with musl
For the smallest and most portable binaries, compile against musl libc:
# Add the musl target
rustup target add x86_64-unknown-linux-musl
# Build
cargo build --release --target x86_64-unknown-linux-musl
On macOS, you need a musl cross-compiler. The easiest path is Docker:
docker run --rm \
-v $(pwd):/app \
-w /app \
rust:alpine \
cargo build --release --target x86_64-unknown-linux-musl
The resulting binary has zero dynamic library dependencies. You can run it in a scratch container (a Docker image with literally nothing in it).
Multi-Stage Docker Build
This is the standard pattern. The first stage compiles. The second stage runs.
# syntax=docker/dockerfile:1
# ─── Build Stage ──────────────────────────────────────────────────────────────
FROM rust:1.75-slim-bookworm AS builder
WORKDIR /app
# Cache dependencies separately from source
# Copy manifests first
COPY Cargo.toml Cargo.lock ./
# Create a dummy source file so cargo can build the dependency tree
RUN mkdir src && echo "fn main() {}" > src/main.rs
# Build dependencies (this layer is cached as long as Cargo.toml/lock don't change)
RUN cargo build --release --locked && rm -f target/release/deps/myapp*
# Now copy the real source and build the application
COPY src ./src
COPY migrations ./migrations
RUN cargo build --release --locked
# ─── Runtime Stage ────────────────────────────────────────────────────────────
FROM debian:bookworm-slim AS runtime
# Install runtime dependencies (only what's needed)
RUN apt-get update && apt-get install -y \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user
RUN useradd -r -s /bin/false -m -d /app appuser
WORKDIR /app
# Copy the binary from the build stage
COPY --from=builder /app/target/release/myapp .
# Copy migrations if you run them at startup
COPY --from=builder /app/migrations ./migrations
USER appuser
EXPOSE 3000
ENV APP_ENV=production
ENV RUST_LOG=info
ENTRYPOINT ["./myapp"]
Using distroless for Even Smaller Images
Google's distroless images contain only the application and its runtime dependencies — no shell, no package manager, no utilities. Smaller attack surface, smaller image:
FROM gcr.io/distroless/cc-debian12 AS runtime
COPY --from=builder /app/target/release/myapp /app/myapp
USER nonroot:nonroot
EXPOSE 3000
ENTRYPOINT ["/app/myapp"]
The cc variant includes the C runtime library (glibc). If you compiled with musl:
FROM scratch AS runtime
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /app/myapp
USER 65534:65534
EXPOSE 3000
ENTRYPOINT ["/app/myapp"]
scratch is an empty image. The binary is the entire container. Image size will be roughly the binary size plus any static data you copy in.
Size Comparison
For a typical Axum service with Tokio, SQLx, and the tracing stack:
| Base image | Approximate size |
|---|---|
rust:1.75 (full SDK) | ~1.7 GB |
debian:bookworm-slim + binary | ~80 MB |
distroless/cc + binary | ~30 MB |
scratch + musl binary | ~8-15 MB |
The difference between debian:bookworm-slim and scratch is not just academic — it matters for pull times, storage costs, and the surface area available to a container escape.
Optimizing Build Times
Rust compile times are long. Docker layer caching helps, but you can do more:
cargo-chef for Better Layer Caching
The cargo-chef tool generates a recipe file from your dependency tree that can be cached independently of your source:
FROM lukemathwalker/cargo-chef:latest-rust-1.75-slim AS chef
WORKDIR /app
# ─── Planner Stage ────────────────────────────────────────────────────────────
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# ─── Builder Stage ────────────────────────────────────────────────────────────
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Build dependencies (cached when recipe.json is unchanged)
RUN cargo chef cook --release --recipe-path recipe.json
# Build application
COPY . .
RUN cargo build --release --locked --bin myapp
# ─── Runtime Stage ────────────────────────────────────────────────────────────
FROM debian:bookworm-slim AS runtime
# ... (same as before)
With cargo-chef, the dependency compilation layer is cached even when your source files change. This turns a 5-minute build into a 30-second build after the first run, which is the difference between "deploy in CI" and "deploy in CI after you've made coffee and checked your email."
Releasing Without Docker
Sometimes the right deployment is just a binary. Linux servers, AWS Lambda (via the lambda_http crate), or bare metal. Build the binary, copy it, run it.
For cross-compilation to different targets:
# From macOS to Linux x86_64
rustup target add x86_64-unknown-linux-gnu
cargo build --release --target x86_64-unknown-linux-gnu
# You'll need a cross-linker; use the `cross` tool for simplicity
cargo install cross
cross build --release --target x86_64-unknown-linux-gnu
cross uses Docker containers with the appropriate cross-compilation toolchain. It's the easiest way to cross-compile without configuring linkers manually.
Health Checks
Your container orchestrator (Kubernetes, ECS, Fly.io) needs to know if your service is healthy. Implement a health endpoint:
#![allow(unused)] fn main() { use axum::{http::StatusCode, response::Json, routing::get, Router}; use serde::Serialize; #[derive(Serialize)] struct HealthResponse { status: &'static str, version: &'static str, } async fn health(State(pool): State<sqlx::PgPool>) -> Result<Json<HealthResponse>, StatusCode> { // Verify database connectivity sqlx::query!("SELECT 1 as check") .fetch_one(&pool) .await .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; Ok(Json(HealthResponse { status: "ok", version: env!("CARGO_PKG_VERSION"), })) } }
Use env!("CARGO_PKG_VERSION") to embed the version from Cargo.toml at compile time. It costs nothing and makes debugging much easier when you're trying to figure out which version is deployed.
In your Dockerfile:
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["./myapp", "--health-check"] \
# or use curl if available:
# CMD curl -f http://localhost:3000/health || exit 1
Environment Variables in Production
Pass configuration via environment variables, not config files baked into the image:
docker run -d \
-e APP_DATABASE_URL="postgres://user:pass@host/db" \
-e APP_AUTH_JWT_SECRET="production-secret" \
-e APP_ENV="production" \
-e RUST_LOG="info" \
-p 3000:3000 \
myapp:latest
Or in a docker-compose.yml for local development (use .env for the actual values, not hardcoded in compose):
services:
app:
image: myapp:latest
ports:
- "3000:3000"
environment:
- APP_DATABASE_URL
- APP_AUTH_JWT_SECRET
- APP_ENV=development
- RUST_LOG=debug
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
The Complete CI/CD Picture
Build, test, push, deploy:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: password
POSTGRES_DB: myapp_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
- name: Install sqlx-cli
run: cargo install sqlx-cli --no-default-features --features rustls,postgres
- name: Run migrations
run: sqlx migrate run
env:
DATABASE_URL: postgres://postgres:password@localhost/myapp_test
- name: Test
run: cargo test
env:
TEST_DATABASE_URL: postgres://postgres:password@localhost/myapp_test
build-and-push:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .
- name: Push to registry
run: |
echo ${{ secrets.REGISTRY_PASSWORD }} | docker login -u ${{ secrets.REGISTRY_USER }} --password-stdin
docker push myapp:${{ github.sha }}
The important bit: run tests before building the release image. Don't push broken images.
Runtime Performance
Rust in production is fast. A few things that matter:
Use --release. Debug builds are 10-100x slower. This is not an exaggeration. Debug builds include bounds checks, no optimizations, and debug assertions. Release builds get inlining, vectorization, and dead code elimination.
Tune the connection pool. Too small: requests queue waiting for connections. Too large: you exhaust the database server's connection limit. Start with 10-20 and tune based on observed pool utilization.
Don't run migrations on every startup in high-availability deployments. Running sqlx::migrate! on startup is fine for single-instance services. For rolling deploys with multiple instances starting simultaneously, migrations can conflict. Use a migration lock or run migrations as a pre-deployment step.
Monitor memory usage. Rust won't leak memory in the same way managed-runtime languages do, but it can still accumulate allocations — unbounded caches, retained connection pools, growing queues. Watch RSS growth over time.