Chapter 25
Packaging & Deployment
The final chapter. We'll build an optimized binary, cross-compile for different platforms, package it in a minimal Docker image, handle shutdown gracefully, and cover what it takes to run in production.
Building Release Binaries
Throughout this guide we've been running cargo run, which compiles in debug mode - no optimizations, extra bounds checking, large binary. For deployment, you want a release build:
# Debug build (default) - fast to compile, slow to run
cargo build
# → target/debug/webserver (15–30 MB, unoptimized)
# Release build - slow to compile, fast to run
cargo build --release
# → target/release/webserver (3–8 MB, fully optimized)The difference is dramatic:
| Debug | Release | |
|---|---|---|
| Compile time | Fast (seconds) | Slow (minutes) |
| Binary size | 15–30 MB | 3–8 MB |
| Runtime speed | Baseline | 10–100x faster |
| Overflow checks | Panics on overflow | Wraps silently |
| Debug symbols | Included | Stripped (configurable) |
Tuning the Release Profile
You can customize the release profile in Cargo.toml for smaller binaries or faster builds:
[profile.release]
opt-level = 3 # Maximum optimization (default)
lto = true # Link-time optimization - slower build, smaller/faster binary
codegen-units = 1 # Single codegen unit - better optimization, slower build
strip = true # Strip debug symbols from the binary
panic = "abort" # Abort on panic instead of unwinding - smaller binaryThe impact of each setting:
lto = true- the linker optimizes across crate boundaries. Can reduce binary size by 10–20% and improve speed. Build time roughly doubles.codegen-units = 1- normally Rust parallelizes compilation by splitting code into units. One unit means better optimization but no parallelism during codegen.strip = true- removes debug symbols. Reduces binary size significantly but you lose stack traces in crash reports.panic = "abort"- instead of unwinding the stack on panic (which allowsDropto run), just abort. Saves ~10% binary size. Only set this if you don't rely on panic recovery.
lto = true and strip = true are almost always worth it. Skip panic = "abort" if you need graceful cleanup on panic (like flushing logs or closing database connections).Cross-Compilation Basics
Rust can compile for platforms other than the one you're developing on. This is essential when you develop on macOS but deploy to a Linux server.
# List available targets
rustup target list
# Add a target
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-unknown-linux-musl
# Build for that target
cargo build --release --target x86_64-unknown-linux-muslCommon targets for server deployment:
| Target | Platform | Notes |
|---|---|---|
| x86_64-unknown-linux-gnu | Linux (x86, glibc) | Most common server target |
| x86_64-unknown-linux-musl | Linux (x86, musl) | Fully static binary - no libc dependency |
| aarch64-unknown-linux-musl | Linux (ARM64, musl) | AWS Graviton, Apple Silicon VMs |
| aarch64-apple-darwin | macOS (Apple Silicon) | Native on M-series Macs |
musl targets produce fully static binaries - no dynamic library dependencies. This means the binary runs on any Linux system regardless of glibc version. Perfect for minimal Docker images built FROM scratch.For cross-compilation with C dependencies (like SQLite), you may need a cross-compilation toolchain. The cross tool handles this with Docker:
cargo install cross
# Build for Linux from macOS - cross handles the toolchain
cross build --release --target x86_64-unknown-linux-muslContainerizing with Docker
A multi-stage Docker build compiles the Rust binary in one stage (with the full toolchain) and copies just the binary into a minimal runtime image. The result: a container under 20 MB.
# Stage 1: Build
FROM rust:1.82-slim AS builder
WORKDIR /app
# Cache dependencies - copy manifests first, build, then copy source
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release && rm -rf src
# Now copy real source and rebuild (only changed files recompile)
COPY src/ src/
COPY migrations/ migrations/
RUN touch src/main.rs && cargo build --release
# Stage 2: Runtime - minimal image
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/webserver /usr/local/bin/webserver
COPY public/ /app/public/
WORKDIR /app
EXPOSE 8080
ENV HOST=0.0.0.0
ENV PORT=8080
ENV LOG_LEVEL=info
CMD ["webserver"]The dependency caching trick is key - Docker caches each layer. By copying Cargo.toml and building dependencies first, they're only recompiled when Cargo.toml changes. Source code changes only rebuild your code, not all dependencies.
Even Smaller: Static musl Build
# Stage 1: Build a fully static binary
FROM rust:1.82-slim AS builder
RUN rustup target add x86_64-unknown-linux-musl
RUN apt-get update && apt-get install -y musl-tools
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl && rm -rf src
COPY src/ src/
COPY migrations/ migrations/
RUN touch src/main.rs && cargo build --release --target x86_64-unknown-linux-musl
# Stage 2: Scratch - literally empty, just the binary
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/webserver /webserver
COPY public/ /public/
EXPOSE 8080
ENTRYPOINT ["/webserver"]FROM scratch - no OS, no shell, no package manager. Just your binary and static files. The resulting image is the size of your binary plus your assets (~5–10 MB total).
# Build and run
docker build -t webserver .
docker run -p 8080:8080 webserver
# Check the image size
docker images webserver
# REPOSITORY TAG SIZE
# webserver latest 12MB.dockerignore to exclude target/, .git/, and other large directories from the build context. Without it, Docker copies your entire target/ directory (which can be gigabytes) into the build context on every build.target/
.git/
*.db
.envGraceful Shutdown and Signal Handling
When you deploy a new version, the old server process receives a SIGTERM signal. A graceful shutdown means: stop accepting new connections, finish processing in-flight requests, flush logs, close database connections, then exit.
Without graceful shutdown, clients get broken connections and database writes may be lost.
use tokio::signal;
use tokio::sync::watch;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().init();
let db = Db::connect("sqlite:todos.db?mode=rwc").await.unwrap();
let app = Arc::new(build_app(db.clone()));
let addr = "0.0.0.0:8080";
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
tracing::info!(addr, "Listening");
// Shutdown signal channel
let (shutdown_tx, shutdown_rx) = watch::channel(false);
// Spawn the accept loop
let server = tokio::spawn({
let shutdown_rx = shutdown_rx.clone();
async move {
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok((stream, _)) => {
let app = Arc::clone(&app);
let mut rx = shutdown_rx.clone();
tokio::spawn(async move {
tokio::select! {
_ = handle_connection(stream, app) => {}
_ = rx.changed() => {
tracing::debug!("Connection cancelled by shutdown");
}
}
});
}
Err(e) => tracing::error!(error = %e, "Accept failed"),
}
}
_ = shutdown_rx.clone().changed() => {
tracing::info!("Shutting down accept loop");
break;
}
}
}
}
});
// Wait for shutdown signal
shutdown_signal().await;
tracing::info!("Shutdown signal received");
// Signal all tasks to stop
let _ = shutdown_tx.send(true);
// Wait for the server task to finish
let _ = server.await;
// Cleanup
tracing::info!("Server stopped");
}
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 => tracing::info!("Received Ctrl+C"),
_ = terminate => tracing::info!("Received SIGTERM"),
}
}The shutdown flow:
shutdown_signal()waits for eitherCtrl+C(interactive) orSIGTERM(deployment).- The
watchchannel broadcasts the shutdown to all tasks. The accept loop breaks, stopping new connections. - Active connections see
shutdown_rx.changed()viaselect!and can finish gracefully or cancel. - The server task is awaited, ensuring all spawned work completes.
- Database connections, log flushing, etc. happen as the runtime shuts down (via
Drop).
SIGTERM first, waits 10 seconds (configurable with stop_grace_period), then sends SIGKILL. Kubernetes uses the same pattern with terminationGracePeriodSeconds. Your server should shut down within that window.Running Your Server in Production
A production deployment involves more than just the binary. Here's a checklist of operational concerns:
Health Check Endpoint
Load balancers and orchestrators need to know if your server is alive. Add a /health endpoint:
router.add(Method::Get, "/health", |_req| {
Ok(Response::json_ok(&serde_json::json!({
"status": "healthy",
"version": env!("CARGO_PKG_VERSION"),
})))
});Environment-Based Configuration
# Don't hardcode any of these
HOST=0.0.0.0 # bind to all interfaces in containers
PORT=8080 # configurable, not hardcoded
DATABASE_URL=sqlite:///data/app.db # external in containers
LOG_LEVEL=info # verbose in staging, minimal in prod
API_TOKEN=... # from secrets manager, never in codeReverse Proxy
In production, your server typically sits behind a reverse proxy (nginx, Caddy, a cloud load balancer) that handles:
- TLS termination
- HTTP/2 and HTTP/3
- Request buffering and size limits
- Static file caching
- Rate limiting and DDoS protection
# nginx.conf - minimal reverse proxy
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Process Management
Use a process manager to restart on crash:
[Unit]
Description=Rust Webserver
After=network.target
[Service]
Type=simple
User=www
ExecStart=/usr/local/bin/webserver
Restart=on-failure
RestartSec=5
Environment=PORT=8080
Environment=LOG_LEVEL=info
Environment=DATABASE_URL=sqlite:///var/lib/webserver/app.db
[Install]
WantedBy=multi-user.targetsudo systemctl enable webserver
sudo systemctl start webserver
sudo journalctl -u webserver -f # follow logsDocker Compose for the Full Stack
services:
webserver:
build: .
ports:
- "8080:8080"
environment:
- HOST=0.0.0.0
- PORT=8080
- LOG_LEVEL=info
- DATABASE_URL=sqlite:///data/app.db
volumes:
- app-data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 5s
retries: 3
volumes:
app-data:What We Built
Let's step back and look at everything we've built across 25 chapters:
You built a webserver from a blank cargo new to a production-deployable application - handling raw TCP, parsing HTTP, routing, JSON APIs, database persistence, middleware, TLS, security, and performance optimization. Every line of networking code, every parser, every middleware layer - written by hand in Rust.
More importantly, you learned Rust itself through building something real. Ownership makes sense when you're deciding whether a TcpStream should be moved or borrowed. Lifetimes click when you're passing parsed headers through a request pipeline.Arc<Mutex<T>> is natural when you need shared request counters across threads.
Where to Go from Here
You have a solid Rust foundation. Here are directions to explore next:
- Axum or Actix Web - production web frameworks that build on the same concepts (Tower services, extractors, async handlers). You'll recognize every pattern.
- Tokio in depth - channels, semaphores, timers, task-local storage, and the
towermiddleware ecosystem. - WebSockets - upgrade HTTP connections to bidirectional streams for real-time features.
- gRPC with Tonic - high-performance RPC framework built on Tokio, for service-to-service communication.
- Embedded Rust - the same ownership model, no standard library, running on microcontrollers.
- WASM - compile Rust to WebAssembly and run it in the browser or edge computing platforms.
Whatever you build next, the ownership model, the type system, and the "if it compiles, it works" confidence will carry forward. You know Rust now. Go build something.