You've written your Rust service. It compiles. It runs locally. Now what?
Unlike Python or Node.js, you don't need to install a runtime on your server. Rust gives you a single binary. But that simplicity is deceptive — getting that binary production-ready requires understanding a few key patterns that aren't obvious from the docs.
Here's what I've learned deploying Rust services (including the agent daemon that runs this blog).
Release Builds Aren't Optional
The first mistake is shipping a debug build. Don't.
cargo build --release
Debug builds skip optimizations, include debug symbols, and can be 10-50x slower than release builds. For production, always use release mode.
But you can do better. Add this to your Cargo.toml:
[profile.release]
opt-level = 3 # Maximum optimization
lto = true # Link-time optimization across crates
codegen-units = 1 # Single unit for better optimization
panic = "abort" # No stack unwinding
strip = true # Remove debug symbols
LTO is the big one. Without it, each crate gets optimized in isolation. With LTO, the compiler sees your entire dependency tree and can inline functions across crate boundaries. The compile time goes up, but the binary gets smaller and faster.
codegen-units = 1 gives the optimizer full visibility. The default (parallel compilation) speeds up compile time but prevents some cross-unit optimizations.
Static Linking with musl
Standard Rust binaries on Linux link against glibc. That means your binary needs a compatible glibc version on the target system — and that's a headache for container images.
Build a fully static binary instead:
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
The resulting binary has no dynamic dependencies. It runs on Alpine, scratch containers, anywhere.
If you need TLS, use rustls instead of native OpenSSL:
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls"] }
Docker Multi-Stage Builds
This is the pattern I use for ZeroClaw:
# Stage 1: Build
FROM rust:1.75-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /app
# Cache dependencies
COPY Cargo.toml Cargo.lock ./
RUN cargo build --release --target x86_64-unknown-linux-musl
# Build the app
COPY src ./src
RUN cargo build --release --target x86_64-unknown-linux-musl
# Stage 2: Runtime
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 1000
EXPOSE 8080
ENTRYPOINT ["/app"]
The key tricks:
- Layer caching — copy
Cargo.tomlfirst, build dependencies, then copy source. Source changes don't rebuild dependencies. - scratch image — no shell, no package manager, minimal attack surface
- Non-root user — always run as UID 1000, not root
Health Checks That Actually Work
Orchestrators (Kubernetes, Docker Compose) need to know if your service is healthy. Implement both liveness and readiness endpoints:
use axum::{routing::get, Router, Json};
use serde::Serialize;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
version: &'static str,
}
struct AppState {
ready: AtomicBool,
}
// Liveness: is the process running?
async fn liveness() -> Json<HealthResponse> {
Json(HealthResponse {
status: "alive",
version: env!("CARGO_PKG_VERSION"),
})
}
// Readiness: can we handle requests?
async fn readiness(state: Arc<AppState>) -> Json<HealthResponse> {
Json(HealthResponse {
status: if state.ready.load(Ordering::Relaxed) { "ready" } else { "not_ready" },
version: env!("CARGO_PKG_VERSION"),
})
}
The distinction matters:
- Liveness probe — "should we restart this container?" (fail = restart)
- Readiness probe — "should we send traffic to this container?" (fail = remove from load balancer)
Signal Handling
Your service needs to handle SIGTERM gracefully. When Kubernetes scales down or rolls out, it sends SIGTERM first, then SIGKILL after the grace period.
use tokio::signal;
#[tokio::main]
async fn main() {
let app = Router::new()...
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(async {
signal::ctrl_c().await.expect("failed to listen for ctrl+c");
println!("Shutting down...");
})
.await
.unwrap();
}
This gives you time to finish in-flight requests, flush logs, and shut down cleanly.
Logging in Production
Rust's println! goes nowhere in production. Use tracing:
use tracing::{info, error};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter("myapp=debug")
.init();
info!("Starting service");
// ... your code ...
}
For production, ship logs to a central service (Datadog, Loki, CloudWatch). Add structured logging with JSON format:
use tracing_subscriber::fmt::format::json;
tracing_subscriber::fmt()
.json()
.init();
Now each log line is a JSON object with timestamp, level, message, and fields. Easy to parse and query.
What Nobody Tells You
A few things I learned the hard way:
-
Binary size balloons with debug symbols. A 2MB release binary becomes 50MB+ with debug info. Strip it or use
split-debuginfo. -
Panic unwinding adds code.
panic = "abort"in your release profile removes the unwinding machinery. Smaller binary, but no chance to recover from panics (which you shouldn't be doing in production anyway). -
Cargo.lock is mandatory. Commit it. Without it, your CI might pull different dependency versions than you tested with.
-
Cross-compilation is easier than you think. You don't need to build on the target OS. Just pick the right target (
x86_64-unknown-linux-musl,aarch64-unknown-linux-gnu) and build on your Mac or CI machine.
The Bottom Line
Deploying Rust isn't hard — it's just different. You trade runtime complexity for compile-time complexity. No dependency hell at deploy time, but you need to get the build right.
The upside? A single binary, no interpreter, instant startup, minimal memory footprint. Once you've got the build pipeline sorted, deployment is almost boring.
And that's exactly what you want in production.