Axum Deployment
Overview
Deploying an Axum service to production has four concerns: a release build, a
small container image, a graceful shutdown that survives orchestrator restarts,
and container hygiene. All deployment patterns in this skill are identical on
Axum 0.7 and 0.8; axum::serve and Serve::with_graceful_shutdown have the
same signature in both versions.
The single most important runtime concern is graceful shutdown. Docker,
Kubernetes, and systemd send SIGTERM on every stop, restart, and rolling
deploy. A plain axum::serve(listener, app).await ignores SIGTERM, so the
process is killed immediately and every in-flight request is dropped. ALWAYS
install a SIGTERM-aware graceful shutdown.
Quick Reference
| Task | Command |
|---|---|
| Release build | cargo build --release |
| Static musl binary | cargo build --release --target x86_64-unknown-linux-musl |
| Add the musl target | rustup target add x86_64-unknown-linux-musl |
| Need | API or artifact |
|---|---|
| Run server with shutdown hook | axum::serve(listener, app).with_graceful_shutdown(signal).await |
| Catch SIGINT (Ctrl+C) | tokio::signal::ctrl_c().await |
| Catch SIGTERM (Unix) | tokio::signal::unix::signal(SignalKind::terminate())?.recv().await |
| Cap drain time per request | tower_http::timeout::TimeoutLayer |
| Close the DB pool on exit | pool.close().await (sqlx) |
| Container bind address | 0.0.0.0:<port>, NEVER 127.0.0.1 |
| Cache the Docker dependency layer | cargo-chef (cargo chef prepare / cargo chef cook) |
with_graceful_shutdown signature (verified, Axum 0.7 and 0.8):
pub fn with_graceful_shutdown<F>(self, signal: F) -> WithGracefulShutdown<L, M, S, F>
where
F: Future<Output = ()> + Send + 'static,
The signal future MUST resolve to (), MUST be Send, and MUST be
'static. When it resolves, the server stops accepting new connections and
drains in-flight requests. The resulting future resolves to io::Result<()>.
tokio::signal requires the tokio signal Cargo feature (the full umbrella
includes it).
Decision Trees
Runtime base image
Choosing the container runtime stage?
├─ Built a static musl binary (x86_64-unknown-linux-musl)?
│ └─ FROM scratch : smallest image, no shell, no libc. Copy ca-certificates
│ explicitly for outbound TLS. Set USER 10001:10001.
├─ Built a default glibc binary, want a shell to debug?
│ └─ FROM debian:bookworm-slim : small, has apt and a shell. Add a non-root
│ USER. Easiest to troubleshoot.
└─ Built a glibc binary, want a minimal attack surface, no debug shell?
└─ FROM gcr.io/distroless/cc-debian12 : no shell or package manager. Use a
:nonroot tag.
When to use cargo-chef
Building the app in Docker?
├─ Iterating, image rebuilt often, dependency tree is large?
│ └─ Use the 3-stage cargo-chef pattern. A source-only change then recompiles
│ only the app crate, not every dependency.
└─ One-off build, tiny dependency tree?
└─ A plain multi-stage build (builder + runtime) is enough.
NEVER ship a single-stage build to production; it embeds the ~1.5 GB Rust toolchain in the final image.
Patterns
Pattern 1: Release build and profile tuning
ALWAYS build production artifacts with --release. The dev profile is
unoptimized and far slower at runtime. The release binary lands in
target/release/<crate-name>.
# Cargo.toml : optional production tuning for a smaller, faster binary
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true # remove debug symbols, shrink the binary
Pattern 2: Static musl binary
A glibc-linked binary cannot run in a scratch or distroless container that
lacks a C library. For a fully static, dependency-free binary, build the musl
target:
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
# output: target/x86_64-unknown-linux-musl/release/<crate-name>
Use rustls instead of native-TLS crates under musl; for an Axum service with
sqlx + rustls this is rarely an issue.
Pattern 3: Graceful shutdown signal future
The signal future MUST handle both SIGINT (interactive Ctrl+C) and
SIGTERM (the orchestrator stop signal). This future is verified verbatim from
the official examples/graceful-shutdown crate:
use tokio::signal;
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
The #[cfg(not(unix))] branch is std::future::pending::<()>(), a future that
never resolves, because SIGTERM does not exist on Windows. tokio::select!
resolves as soon as either signal arrives.
Pattern 4: Wiring serve, TimeoutLayer, and resource release
#[tokio::main]
async fn main() {
// ... tracing setup, build the DB pool, build the Router ...
let app = Router::new()
.route("/slow", get(|| sleep(Duration::from_secs(5))))
.layer((
TraceLayer::new_for_http(),
// Cap the drain: a request exceeding the timeout gets 408
// instead of blocking graceful shutdown forever.
TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(10),
),
));
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
// Drain has completed here. Release process-wide resources:
pool.close().await; // sqlx: returns and closes all connections
// Flush buffered tracing / OpenTelemetry spans before exit.
}
Two rules from this verified pattern:
- ALWAYS pair graceful shutdown with a
TimeoutLayer. A request that never completes (for example a hung upstream call) otherwise blocks the drain forever. - ALWAYS release process-wide resources AFTER
axum::serve(...).awaitreturns:pool.close().awaitso connections are returned cleanly, and flush the tracing exporter so no spans are lost.
Pattern 5: Multi-stage Dockerfile
Compile in a rust builder stage, copy ONLY the binary into a minimal runtime
stage. This shrinks the image from ~1.5 GB to tens of MB.
# ---- Builder stage ----
FROM rust:1-bookworm AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
# ---- Runtime stage ----
FROM debian:bookworm-slim AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --uid 10001 --no-create-home appuser
USER appuser
COPY --from=builder /app/target/release/myapp /usr/local/bin/myapp
EXPOSE 3000
ENV RUST_LOG=info
CMD ["myapp"]
ca-certificates is needed when the app makes outbound HTTPS calls. See
references/examples.md for the scratch + musl variant.
Pattern 6: cargo-chef dependency-layer caching
COPY . . then cargo build invalidates the Docker layer cache on EVERY
source change, forcing a full recompile of all dependencies. cargo-chef
caches dependencies in a layer keyed only on Cargo.toml / Cargo.lock.
FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Cached layer: rebuilds ONLY when recipe.json (dependencies) changes.
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --bin myapp
# ... minimal runtime stage as in Pattern 5 ...
For a musl static build, add --target x86_64-unknown-linux-musl to BOTH
cargo chef cook and cargo build so the cooked artifacts match the final
target.
Pattern 7: Container hygiene checklist
- ALWAYS bind
0.0.0.0, NEVER127.0.0.1. Inside a container127.0.0.1is unreachable from the host's published port. - ALWAYS read
DATABASE_URL, secrets, the port, andRUST_LOGfromstd::env::var(...). NEVER bake secrets or connection strings into source. - ALWAYS install the
SIGTERM-aware graceful shutdown (Patterns 3 and 4). - ALWAYS run as a non-root user (
USERdirective, or a:nonrootdistroless tag, orUSER 10001:10001forscratch). - ALWAYS add a
.dockerignorefortarget/,.git/, and local.envfiles. EXPOSE 3000documents the port; publish it withdocker run -p.
ALWAYS / NEVER
- ALWAYS call
.with_graceful_shutdown(signal)onaxum::serve. NEVER ship a plainaxum::serve(listener, app).await;SIGTERMthen drops every in-flight request on each deploy. - ALWAYS handle both
SIGINTandSIGTERMin the signal future. NEVER handle onlyctrl_c(); orchestrators sendSIGTERM, notSIGINT. - ALWAYS pair graceful shutdown with a
TimeoutLayer. NEVER drain unbounded; a hung request blocks shutdown forever. - ALWAYS use a multi-stage Docker build. NEVER ship a single-stage image; it embeds the ~1.5 GB toolchain.
- ALWAYS build a musl static binary for a
scratchruntime. NEVER put a glibc binary inscratch; it fails to start with no C library present. - ALWAYS bind
0.0.0.0in a container. NEVER bind127.0.0.1there. - ALWAYS close the DB pool and flush tracing after
servereturns. NEVER letmain()exit while connections are still checked out.
Reference Links
references/methods.md: verified signatures foraxum::serve,Serve::with_graceful_shutdown,tokio::signal::ctrl_c,tokio::signal::unix::signal,SignalKind,TimeoutLayer,Pool::close.references/examples.md: fullmain.rswith graceful shutdown and resource release, env-var config, thescratch+ musl Dockerfile, the full cargo-chef Dockerfile, and a.dockerignore.references/anti-patterns.md: real deployment mistakes, the failure each produces, and the fix.
Cross-references:
axum-impl-tracing: configuring and flushing the tracing subscriber.axum-impl-database: building thesqlxpool that Pattern 4 closes.axum-core-async-performance: pool tuning and never blocking the runtime.