Chapter 09 · from source to shippable

One command, one self-contained binary.

A Rust build produces a single native executable with no separate runtime to install on the target — closer to a .NET self-contained, single-file, AOT publish than to the framework-dependent default you're used to. This chapter covers building it, shrinking it to one static file, cross-compiling, and putting it in a container.

9.1Debug vs release, and where the binary lands

Two builds, same as dotnet build vs -c Release. The debug build is fast to compile and slow to run; the release build is the opposite and is the one you ship.

shell# fast compile, unoptimized — for the dev loop
cargo build            # → target/debug/rust-for-csharp

# optimized — the artifact you deploy
cargo build --release  # → target/release/rust-for-csharp

The output is a normal executable named after the package (rust-for-csharp, or rust-for-csharp.exe on Windows). You can run it directly — ./target/release/rust-for-csharp — without cargo present at all.

Don't benchmark debug

As covered in chapter 7, a debug binary can be 10–100× slower. Any timing, profiling, or "is Rust actually fast?" check must use --release.

9.2One self-contained file — the .NET contrast

This is where a habit from .NET stops applying. In .NET you choose a deployment model; in Rust the equivalent of "the good one" is simply the default and there is no runtime to ship alongside it.

.NET conceptIn Rust
Framework-dependent (needs the runtime installed)doesn't exist — there's no separate runtime to install
Self-contained (bundles the runtime)the default — everything Rust-side is in the binary
PublishSingleFilethe default — one executable
Native AOT (PublishAot)the default — compiled ahead of time, no JIT

"Self-contained," though, has one asterisk. By default the binary statically links the Rust standard library and every crate you used, but it still dynamically links the operating system's C library (glibc on most Linux, libSystem on macOS). So it's one file, but it expects a compatible system C library to be present where it runs — which is why a binary built on a newer Linux can fail on an older one with a glibc-version error.

9.3A fully static build with musl

To get a binary with no external dependency at all — the thing you can drop into an empty container or an unknown Linux box — target musl, an alternative C library that links statically. The result needs nothing on the host: no glibc, no shared libraries.

shell# add the target once
rustup target add x86_64-unknown-linux-musl

# build against it (output under target/x86_64-unknown-linux-musl/release/)
cargo build --release --target x86_64-unknown-linux-musl
The closest analogy

A static musl binary is the spirit of a .NET self-contained, single-file, trimmed, AOT publish: a stand-alone artifact that assumes nothing about the machine it lands on. The difference is that here it's a target choice, not a bundle of the runtime.

Smaller is often better for distribution. A few release-profile knobs trim size; add them to Cargo.toml:

Cargo.toml[profile.release]
opt-level = "z"     # optimize for size (vs 3 for speed)
lto = true         # link-time optimization
codegen-units = 1  # less parallelism, better optimization
strip = true       # drop debug symbols (≈ stripping a native lib)
panic = "abort"  # no unwinding tables — smaller, but no catch_unwind

9.4Cross-compiling to another OS or CPU

Rust names every platform with a target triple like x86_64-unknown-linux-gnu or aarch64-apple-darwin. You can build for a target other than your own, but it needs a linker that understands that platform — which is the part that gets fiddly.

The path of least resistance is cross, which runs the build inside a Docker container that already has the right toolchain, so you don't install one by hand:

shellcargo install cross
cross build --release --target x86_64-unknown-linux-musl

Doing it without cross means installing a cross-linker yourself (for example, building a Linux binary from a Mac needs a Linux-targeting linker). For shipping to a server, the simpler route is usually to build inside Docker for the target directly — which is exactly the next section.

9.5What each platform needs installed

Rust itself comes from rustup on all three platforms. The catch is that the compiler hands the final step to the system linker, so you need a C toolchain present even for pure-Rust projects.

PlatformWhat you need
Linux (Debian/Ubuntu)build-essential (gives you gcc/cc + the linker). For a musl build, also musl-tools.
macOSXcode Command Line Tools: xcode-select --install (provides clang and the linker).
WindowsThe default MSVC target needs the Visual Studio Build Tools with "Desktop development with C++" (the MSVC linker + Windows SDK). rustup prompts you to install these. Alternatively use the GNU target (x86_64-pc-windows-gnu) with MinGW-w64.
Native dependencies

Pure-Rust crates need nothing beyond the linker — and that includes this project (axum, tokio, and tower-http are all pure Rust, with no TLS, so it builds with just rustup + a C toolchain). The extra system packages enter the picture when a crate wraps a C library: a TLS crate using OpenSSL typically wants pkg-config and libssl-dev on Linux, for instance. Check a crate's docs when its build fails looking for a .h or a -l library — that's the Rust equivalent of a P/Invoke target missing its native .dll/.so.

9.6Packaging for Docker

The repo ships a Dockerfile; this section is the why behind it. The standard shape is a multi-stage build — analogous to building with the .NET SDK image and then copying the output onto a smaller runtime image. Stage one compiles the release binary; stage two is a slim image holding just the binary (plus, here, the public/ folder, since the guide pages are served from disk).

Dockerfile (shape)# stage 1: compile with the full Rust toolchain
FROM rust:1-slim-bookworm AS builder
WORKDIR /app
COPY . .
RUN cargo build --release

# stage 2: ship only the artifact on a slim base
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/target/release/rust-for-csharp ./
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["./rust-for-csharp"]

Because the default binary links glibc (9.2), the runtime stage uses a glibc base like debian:slim. If you build the static musl binary instead, the runtime image can be almost nothing:

Dockerfile (static → scratch)FROM rust:1-slim-bookworm AS builder
RUN rustup target add x86_64-unknown-linux-musl && apt-get update && apt-get install -y musl-tools
WORKDIR /app
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl

# nothing but the static binary — no OS userland at all
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/rust-for-csharp /
COPY --from=builder /app/public /public
WORKDIR /
EXPOSE 3000
CMD ["/rust-for-csharp"]
Two reasonable defaults

glibc + debian:slim is the easy, robust choice and what this repo uses — a shell and standard tools are there if you need to debug. musl + scratch (or gcr.io/distroless/static) yields a tiny image with a minimal attack surface, at the cost of no shell inside and the occasional musl-specific quirk. Start with the first; reach for the second when image size or surface genuinely matters.

Architecture, not OS

The container's base image carries its own Linux userland, so the host distribution doesn't matter — but the CPU architecture does. Building on an Apple Silicon (arm64) Mac for an x64 server means building for linux/amd64 explicitly: docker buildx build --platform linux/amd64 …. See the README for the full deploy commands.