ARC2 Proxy
Rust Programming Language · By Milen Denev

Graceful Shutdown in Axum and Tokio

Stopping a server correctly is where the subtle bugs live. This article shows how to implement graceful shutdown in Axum 0.7 and Tokio — catching both SIGINT and SIGTERM, draining in-flight requests with a bounded timeout via with_graceful_shutdown and axum-server handles, and cancelling background tasks cleanly on every deploy.

Graceful Shutdown in Axum and Tokio

Introduction

Starting a server is easy; stopping one correctly is where the subtle bugs live. When your process receives a shutdown signal — a Ctrl+C in development, or a SIGTERM from systemd, Docker, or Kubernetes in production — the naive behaviour is to drop everything instantly. That means in-flight requests are cut off mid-response, database transactions are abandoned, and clients see connection resets. Graceful shutdown is the discipline of letting the server stop accepting new work while allowing existing work to finish, within a bounded time. This article shows how to do it properly in Axum 0.7 and Tokio.

Why it matters

In any modern deployment, your process will be told to stop far more often than it will crash. Rolling deploys, autoscaling, node drains, and config changes all send your server a termination signal and then wait a grace period before forcibly killing it. If you shut down abruptly during that window, every request currently being served fails. For a reverse proxy or API sitting on the critical path, that translates directly into user-visible errors on every single deploy — exactly the moment you most want things to be smooth.

A correct shutdown sequence looks like this:

  • Receive the shutdown signal (SIGTERM / SIGINT).
  • Stop accepting new connections.
  • Let in-flight requests run to completion — up to a timeout.
  • Flush anything buffered (logs, metrics, cache writes) and release resources.
  • Exit with a success code.

Listening for the shutdown signal

Tokio's tokio::signal module gives you async signal handling. On Unix you want to catch both SIGINT (Ctrl+C) and SIGTERM (what orchestrators send), because most process managers use SIGTERM and will never trigger a Ctrl+C-only handler:

async fn shutdown_signal() {
    use tokio::signal;

    let ctrl_c = async {
        signal::ctrl_c().await.expect("install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("install SIGTERM handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    // Resolve as soon as EITHER signal arrives.
    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }

    tracing::info!("shutdown signal received");
}

Graceful shutdown with axum::serve

In Axum 0.7 the plain-HTTP server exposes with_graceful_shutdown, which takes a future. When that future resolves, the server stops accepting new connections and waits for in-flight requests to finish before returning:

use axum::{routing::get, Router};

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

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();

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

    // We only reach here once all connections have drained.
    tracing::info!("server stopped cleanly");
}

Graceful shutdown with axum-server (TLS)

When you terminate TLS with axum-server, the pattern is different: you drive shutdown through a Handle. Call graceful_shutdown(Some(duration)) to stop accepting connections and give in-flight requests a bounded grace period before the server forces the remaining ones closed:

use axum_server::Handle;
use std::time::Duration;

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

    let handle = Handle::new();

    // Spawn a task that trips the handle when a signal arrives.
    tokio::spawn({
        let handle = handle.clone();
        async move {
            shutdown_signal().await;
            // Allow up to 30s for in-flight requests, then force close.
            handle.graceful_shutdown(Some(Duration::from_secs(30)));
        }
    });

    let tls = axum_server::tls_rustls::RustlsConfig::from_pem_file("cert.pem", "key.pem")
        .await
        .unwrap();

    axum_server::bind_rustls("0.0.0.0:443".parse().unwrap(), tls)
        .handle(handle)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

The bounded timeout is important. Without it, a single stuck request — a slow upstream, a client that never finishes sending — could hold shutdown open indefinitely and get your process force-killed by the orchestrator anyway. A finite grace period (5–30 seconds is typical) lets the well-behaved requests finish while capping how long a misbehaving one can delay the exit.

Draining background tasks

A real service is more than its request handlers. Background tasks — a cache-refresh loop, a metrics flusher, an uptime prober — also need to be told to stop. The idiomatic tool is a CancellationToken from tokio-util, or a broadcast channel. Each long-lived task selects on its own work and the cancellation signal:

use tokio_util::sync::CancellationToken;

async fn refresh_loop(cancel: CancellationToken) {
    let mut tick = tokio::time::interval(std::time::Duration::from_secs(5));
    loop {
        tokio::select! {
            _ = tick.tick() => {
                // ... do periodic work ...
            }
            _ = cancel.cancelled() => {
                tracing::info!("refresh loop draining");
                break;
            }
        }
    }
}

On shutdown you call cancel.cancel() after the HTTP server has drained, then await the task handles (or a JoinSet) so any final flush completes before the process exits.

Common pitfalls

  • Only handling Ctrl+C. Orchestrators send SIGTERM. If you do not handle it, your "graceful" shutdown never runs in production and every deploy hard-kills the process.
  • No timeout. Unbounded draining lets one hung connection stall the whole shutdown until an external kill.
  • Forgetting background tasks. The HTTP server drains, but a detached task is still writing to a buffer that never flushes. Thread cancellation through everything long-lived.
  • Grace period longer than the orchestrator's. If Kubernetes gives you 30s and your grace is 60s, you get SIGKILLed anyway. Keep your timeout comfortably under the platform's.

Conclusion

Graceful shutdown is a small amount of code that pays off on every single deploy. Catch both SIGINT and SIGTERM, hand the signal to with_graceful_shutdown or an axum-server handle with a bounded timeout, and cancel your background tasks in order. For infrastructure like a reverse proxy — where an abrupt exit means resetting live connections for every user — this is the difference between deploys that are invisible and deploys that generate a spike of errors. It is exactly the kind of correctness detail that separates a toy server from a production one.

Keep reading