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 imageApproximate 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.