Chapter 16: Deployment & Operations
Writing the code is half the job; shipping and running it is the other half. This chapter covers the practical lifecycle of a Rust service in production: release builds, container images, configuration, health checks, graceful shutdown, observability, and zero-downtime updates. Rust's static, single-binary output makes most of this remarkably easy.
Learning Objectives
- Produce optimized release binaries and understand what
--releasedoes. - Package an application as a minimal Docker image.
- Configure with environment variables and files, for twelve-factor deployment.
- Implement health checks and graceful shutdown.
- Observe a running service with logs, metrics, and traces.
16.1 Release builds
Debug builds are for development; production runs cargo build --release. The release profile turns on optimizations (opt-level = 3), disables debug assertions, and uses the system allocator tuned for throughput. For a service, consider tightening it:
# Cargo.toml
[profile.release]
lto = "thin" # link-time optimization across crates
codegen-units = 1 # better optimization, slower compile
strip = true # remove debug symbols for a smaller binary
panic = "abort" # smaller binary, no unwinding overhead
panic = "abort" is a trade-off: smaller, faster binaries, but no stack unwinding — a panicking thread tears down the whole process, which is often what you want in a service supervised by a restart policy.
16.2 A minimal Docker image
Rust produces a statically linked (or near-static) binary, so the runtime image can be tiny. A multi-stage build compiles in a full image and copies the binary into scratch or debian:bookworm-slim:
# Build stage
FROM rust:1.78 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/myapp /usr/local/bin/myapp
RUN useradd -r -s /bin/false appuser
USER appuser
EXPOSE 8080
ENTRYPOINT ["myapp"]
The result is an image of tens of megabytes, not gigabytes, with no toolchain inside — a smaller attack surface and faster pulls.
16.3 Configuration
Keep configuration in the environment, following the twelve-factor methodology. A typical setup reads environment variables, with a local file for development:
#![allow(unused)] fn main() { use std::env; struct Config { port: u16, database_url: String, log_level: String, } impl Config { fn from_env() -> Result<Self, String> { Ok(Config { port: env::var("PORT").unwrap_or_else(|_| "8080".into()).parse().map_err(|_| "PORT not a number")?, database_url: env::var("DATABASE_URL").map_err(|_| "DATABASE_URL missing")?, log_level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".into()), }) } } }
Never bake secrets into the image. Inject them at runtime from a secret manager or orchestration platform (Kubernetes secrets, Docker secrets, cloud secret managers).
16.4 Health checks and readiness
Orchestrators need to know whether your service is alive and ready. Expose two endpoints:
/health(liveness) — "the process is up." Returns200unconditionally; used to decide whether to restart the container./ready(readiness) — "I can handle traffic." Returns200only when the database is connected and warm-up is complete; used to decide whether to route traffic.
#![allow(unused)] fn main() { use axum::{routing::get, Router, http::StatusCode}; let app = Router::new() .route("/health", get(|| async { StatusCode::OK })) .route("/ready", get(|| async { StatusCode::OK })); }
A failing dependency should make /ready return 503, not crash the process.
16.5 Graceful shutdown
When a deployment rolls, the orchestrator sends SIGTERM and waits a short grace period before SIGKILL. Your service should stop accepting new connections, finish in-flight requests, and exit. axum::serve supports this directly:
use axum::{routing::get, Router}; async fn handler() -> &'static str { "ok" } #[tokio::main] async fn main() { let app = Router::new().route("/", get(handler)); let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await .unwrap(); } async fn shutdown_signal() { use tokio::signal; let ctrl_c = async { signal::ctrl_c().await.expect("install ctrl-c handler"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("install terminate handler") .recv() .await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, } println!("shutdown signal received"); }
Combined with the readiness check, this gives zero-downtime rolling updates: traffic drains before the process exits.
16.6 Observability
A production service needs three signals: logs, metrics, and traces.
- Logs — structured, via
tracing. Emit JSON so a log aggregator can index it. - Metrics — counters and histograms via
prometheusormetricscrate, exposed at/metricsfor scraping. - Traces — distributed spans via
tracing-opentelemetry, so you can follow a request across services.
#![allow(unused)] fn main() { tracing_subscriber::fmt() .json() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); }
The single most useful metric is the RED set: Rate, Errors, Duration of requests. Track those per route and you have most of what operations needs.
16.7 Best Practices
- Build once, run anywhere. A release binary that reads its config from the environment works in Docker, systemd, or Kubernetes unchanged.
- Fail fast on config. If a required variable is missing, exit with a clear message at startup — do not limp along.
- Keep the runtime image small.
scratchor a slim base, no toolchain, no source. - Implement both health endpoints. Liveness is not readiness.
- Handle
SIGTERM. Graceful shutdown is what makes rolling updates safe. - Observe from day one. Retrofitting logs and metrics is painful.
16.8 Summary
A Rust service ships as a single, optimized binary in a small container, configured by environment variables, supervised via health checks, and drained gracefully on SIGTERM. The release profile and a multi-stage Docker image are the mechanical core; liveness/readiness endpoints, graceful shutdown, and structured observability are what make it operable in production. Rust's output is unusually easy to deploy — spend that ease on good operational hygiene.
Exercises
- Configure the release profile with
lto,strip, andpanic = "abort", and compare binary size and startup time. - Write a multi-stage Dockerfile that builds your Axum service and runs it as a non-root user.
- Add
/healthand/readyendpoints and aSIGTERMgraceful-shutdown handler. - Emit JSON logs with
tracing_subscriberand filter them by level throughRUST_LOG.