Deploying Rust Applications
Prerequisites: Security & supply chain
A Rust service compiles to a single native binary with no runtime to install. That makes deployment simple — as long as you build it for the right target, keep images small, check every change in CI, and shut down cleanly.
Release builds and profiles
cargo build --release writes an optimised binary to
target/release/<name>. Profiles control how it is built. Besides the
built-in dev and release, you can define your own that inherit from
them — useful for a slower, fully optimised build used only for shipping:
[profile.release]
debug = "line-tables-only" # readable panic backtraces and profiles
[profile.dist]
inherits = "release"
lto = "fat"
codegen-units = 1
strip = "symbols"
cargo build --profile dist # output in target/dist/
Cross-compiling and static binaries
Rust compiles for many targets (<arch>-<vendor>-<os>-<abi>). Adding
one installs its standard library:
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
The binary lands in target/x86_64-unknown-linux-musl/release/. Linking
against musl instead of glibc produces a fully static binary that runs
on any x86-64 Linux, even an empty scratch container. Two caveats: crates
that compile C code need a musl C toolchain, and musl's allocator is slower
under heavy multi-threaded allocation (some services switch to mimalloc).
Other targets (say aarch64-unknown-linux-gnu) need a matching C linker, set
per target in .cargo/config.toml ([target.<triple>] linker = "...").
When that gets tedious, cross (runs the build in a prepared Docker image)
and cargo zigbuild (uses Zig as the cross-linker) handle the toolchains
for you.
A multi-stage Dockerfile
Build in a full Rust image, then copy only the binary into a small runtime image. Building the dependencies in a separate layer means Docker's cache skips them when only your code changes:
FROM rust:1.85-bookworm AS build
WORKDIR /app
# 1. Build dependencies only, using a placeholder main.rs (cached layer).
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs \
&& cargo build --release \
&& rm -rf src
# 2. Build the real application.
COPY . .
RUN touch src/main.rs && cargo build --release
# 3. Minimal runtime image.
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --system --uid 10001 app
COPY --from=build /app/target/release/myapp /usr/local/bin/myapp
USER app
ENV PORT=3000
EXPOSE 3000
CMD ["myapp"]
The final image is tens of megabytes instead of the gigabyte-plus build
image, runs as a non-root user, and contains no compiler. cargo-chef is a
more robust version of the dependency-caching trick for workspaces. With a
musl build you can use FROM scratch or a distroless image instead of
Debian. If you use SQLx's query! macros, copy the .sqlx/ directory and
set ENV SQLX_OFFLINE=true in the build stage.
Continuous integration
Every push should prove the code is formatted, lint-clean and tested. A GitHub Actions workflow:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --all-features -- -D warnings
- run: cargo test --all-features
-D warnings turns every Clippy warning into an error, so warnings can't
pile up. Add cargo audit or cargo deny check (previous lesson) as a
separate job.
Graceful shutdown
Orchestrators (Kubernetes, ECS, systemd) stop a service by sending
SIGTERM, waiting, then killing it. A graceful server stops accepting new
connections and lets in-flight requests finish. Axum supports this
directly:
use axum::{Router, routing::get};
use tokio::net::TcpListener;
use tokio::signal;
async fn health() -> &'static str {
"ok"
}
#[tokio::main]
async fn main() {
// Configuration comes from the environment.
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
let addr = format!("0.0.0.0:{port}");
let app = Router::new().route("/health", get(health));
let listener = TcpListener::bind(&addr).await.expect("failed to bind");
println!("listening on {addr}");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.expect("server error");
println!("shut down cleanly");
}
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c().await.expect("failed to listen for Ctrl+C");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to listen for SIGTERM")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
println!("shutdown signal received; finishing in-flight requests");
}
After shutdown returns, main can flush logs and close the database pool
(pool.close().await) before exiting. Pair this with a /health endpoint
for the orchestrator's readiness checks, and log to stdout so the platform
can collect it.
Common mistakes
- Shipping a debug build. Check that your Dockerfile and scripts say
--release(or your custom profile). - Building for the wrong target — a binary linked against a newer glibc
than the server has fails with
GLIBC_2.xx not found. Build on the same base distribution, or use musl. - Missing CA certificates in a slim image, so outgoing HTTPS fails.
- Ignoring
SIGTERM, so every deploy cuts off requests mid-flight.
Practice
Extend the graceful-shutdown server so the port comes from PORT (default
8080), and a GET /version route returns the crate version, which Cargo
exposes at compile time through env!("CARGO_PKG_VERSION").
Show solution
use axum::{Router, routing::get};
use tokio::net::TcpListener;
async fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
#[tokio::main]
async fn main() {
let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string());
let addr = format!("0.0.0.0:{port}");
let app = Router::new().route("/version", get(version));
let listener = TcpListener::bind(&addr).await.expect("failed to bind");
println!("listening on {addr}");
axum::serve(listener, app)
.with_graceful_shutdown(async {
tokio::signal::ctrl_c().await.expect("failed to listen for Ctrl+C");
})
.await
.expect("server error");
}
curl localhost:8080/version returns the version field from your
Cargo.toml, such as 0.1.0. The value is baked into the binary, so it
always matches what was deployed.
Where to go from here
You now know the language: ownership, traits, generics, lifetimes, error handling, concurrency, async, macros, unsafe, FFI, and the tooling around building, testing and shipping. The next step is not another tutorial — it is building real things. Pick a project you would actually use (a CLI for your own workflow, a small web service, a parser for a file format you deal with), read other people's code in crates you depend on, and let the compiler's feedback keep teaching you.