Skip to content

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:

DebugRelease
Compile timeFast (seconds)Slow (minutes)
Binary size15–30 MB3–8 MB
Runtime speedBaseline10–100x faster
Overflow checksPanics on overflowWraps silently
Debug symbolsIncludedStripped (configurable)

Tuning the Release Profile

You can customize the release profile in Cargo.toml for smaller binaries or faster builds:

Cargo.toml
[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 binary

The 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 allows Drop to run), just abort. Saves ~10% binary size. Only set this if you don't rely on panic recovery.
Tip
For production servers, 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-musl

Common targets for server deployment:

TargetPlatformNotes
x86_64-unknown-linux-gnuLinux (x86, glibc)Most common server target
x86_64-unknown-linux-muslLinux (x86, musl)Fully static binary - no libc dependency
aarch64-unknown-linux-muslLinux (ARM64, musl)AWS Graviton, Apple Silicon VMs
aarch64-apple-darwinmacOS (Apple Silicon)Native on M-series Macs
Note
The 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-musl

Containerizing 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.

Dockerfile
# 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

Dockerfile.musl
# 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
Tip
Use .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.
.dockerignore
target/
.git/
*.db
.env

Graceful 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.

src/main.rs
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:

  1. shutdown_signal() waits for either Ctrl+C (interactive) or SIGTERM (deployment).
  2. The watch channel broadcasts the shutdown to all tasks. The accept loop breaks, stopping new connections.
  3. Active connections see shutdown_rx.changed() via select! and can finish gracefully or cancel.
  4. The server task is awaited, ensuring all spawned work completes.
  5. Database connections, log flushing, etc. happen as the runtime shuts down (via Drop).
Note
Docker sends 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 code

Reverse 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:

/etc/systemd/system/webserver.service
[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.target
sudo systemctl enable webserver
sudo systemctl start webserver
sudo journalctl -u webserver -f  # follow logs

Docker Compose for the Full Stack

docker-compose.yml
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:

1–4Rust fundamentals - variables, types, ownership, structs, enums, pattern matching
5–6TCP listener and HTTP parsing - raw networking with std::net, parsing requests and building responses by hand
7–8Collections and error handling - HashMap routing table, the ? operator, custom error types, graceful HTTP errors
9–11Code organization - modules, traits, generics, closures, the Handler trait and middleware closures
12–13Concurrency - thread pool, Arc/Mutex, async/await with Tokio, thousands of concurrent connections
14–15JSON API and file serving - Serde, CRUD REST endpoints, static files with MIME detection and security
16–18Advanced Rust - lifetimes in practice, smart pointers, Cow, macros for reducing boilerplate
19–20Testing and operations - unit/integration/E2E/property tests, tracing, clap CLI, configuration
21–22Middleware and databases - composable Service/Middleware traits, auth/CORS/logging, SQLite with sqlx
23–25Production readiness - security hardening, performance profiling, Docker deployment, graceful shutdown

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 tower middleware 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.