Rust Axum Graceful Shutdown, The Ultimate Guide: Fix the pitfall 99% of engineers hit

Follow Moshou Coding on WeChat to learn Rust the easy way.

Do these production nightmares look familiar? You reboot the server and lose user data. DB connections fail and transactions get rolled back. WebSocket links drop and the UX craters. The root cause is a detail 99% of developers overlook — Rust Axum graceful shutdown.

Let’s make it practical and friendly while staying production‑grade.

Why graceful shutdown matters in Rust Axum

A robust graceful shutdown mechanism helps you:

  • Complete in‑flight HTTP requests to prevent data loss
  • Ensure DB transactions commit/rollback correctly
  • Notify real‑time clients (WebSocket, SSE) before closing
  • Meet availability and compliance requirements

Step 1: Build the signal listener

Listen for SIGINT (Ctrl+C) and SIGTERM (system/Docker) with Tokio:

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 => {},
    }

    println!("Shutdown signal received. Preparing graceful exit...");
}

Step 2: Teach Axum to shut down gracefully

Use with_graceful_shutdown(shutdown_signal()) so the server stops accepting new connections while finishing in‑flight ones.

Step 3: Stop background tasks cleanly

Use CancellationToken to propagate a stop signal to long‑running tasks:

use tokio_util::sync::CancellationToken;

async fn background_task(token: CancellationToken) {
    while !token.is_cancelled() {
        println!("background worker running...");
        tokio::select! {
            _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
            _ = token.cancelled() => {
                break;
            }
        }
    }
    println!("background worker finished cleanup");
}

Final assembly

use axum::{routing::get, Router};
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;

async fn hello() -> &'static str {
    "Hello, World!"
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(hello));

    let cancellation_token = CancellationToken::new();
    let token_clone = cancellation_token.clone();

    let background_handle = tokio::spawn(background_task(token_clone));

    let listener = TcpListener::bind(("127.0.0.1", 3000)).await.unwrap();
    println!("listening on {:?}", listener.local_addr().unwrap());

    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await
        .unwrap();

    cancellation_token.cancel();

    if let Err(e) = background_handle.await {
        eprintln!("background task join error: {}", e);
    }

    println!("shutdown complete");
}

Production best practices

  • Timeout: 30–60s window to finish in‑flight work
use tokio::time::{timeout, Duration};

let shutdown_timeout = Duration::from_secs(30);
if let Err(_) = timeout(shutdown_timeout, graceful_shutdown).await {
    eprintln!("graceful shutdown timed out; exiting");
}
  • Observability: log start/finish, in‑flight counts, error rates
  • Health checks: degrade readiness during draining to stop new traffic
  • DB pools: close gracefully and await inflight queries

FAQ

  • Docker SIGTERM: Docker sends SIGTERM first; handle it and avoid relying on SIGKILL
  • Kubernetes: set terminationGracePeriodSeconds (typically 60–120s) and use PreStop hooks if needed
  • Long background work: persist progress and resume after restart

Wrap‑up

With signal handling, connection draining, background task cancellation, and resource cleanup, your Axum service will shut down gracefully and safely across bare‑metal, Docker, and Kubernetes.