Updated Aug 27, 2026

13. Graceful Shutdown

📋 Jump to Takeaways

🎁 When Kubernetes sends SIGTERM, your service has a window to finish in-flight requests. Without graceful shutdown, those requests get cut off mid-response. With it, you drain cleanly and exit with code 0.

The Problem

axum::serve runs forever. A CTRL+C or SIGTERM kills the process immediately — any requests in flight are dropped.

Graceful shutdown means:

  1. Stop accepting new connections
  2. Wait for in-flight requests to complete
  3. Close the database pool
  4. Exit cleanly

Signal Handling

Tokio provides tokio::signal for catching OS signals:

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 SIGTERM handler")
            .recv()
            .await;
    };

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

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
}

tokio::select! waits for whichever signal arrives first. On non-Unix platforms (Windows), SIGTERM doesn't exist — pending() is a future that never resolves.

Wiring into axum::serve

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

.with_graceful_shutdown takes a future. When that future resolves, Axum stops accepting new connections and waits for in-flight requests to complete.

Cleanup After Shutdown

Run cleanup code after serve returns:

#[tokio::main]
async fn main() {
    dotenvy::dotenv().ok();
    let config = Config::from_env().unwrap_or_else(|e| {
        eprintln!("configuration error: {}", e);
        std::process::exit(1);
    });

    let pool = create_pool(&config.database_url).await;
    run_migrations(&pool).await;

    let state = AppState { pool: pool.clone(), config };
    let app = build_router(state);
    let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();

    tracing::info!("listening on {}", listener.local_addr().unwrap());

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

    // serve() returned — shutdown signal was received and all requests drained
    pool.close().await; // close database connections cleanly
    tracing::info!("shutdown complete");
}

pool.close() waits for all in-flight database queries to finish and closes all connections. Without it, the process exits while queries are still running — not catastrophic for SQLx, but unclean.

Shutdown Timeout

Production services add a maximum drain time — if a request takes too long, give up:

use std::time::Duration;
use tokio::time::timeout;

async fn main() {
    // ...

    let serve = axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal());

    if let Err(_) = timeout(Duration::from_secs(30), serve).await {
        tracing::warn!("shutdown timed out after 30s — forcing exit");
    }

    pool.close().await;
}

Kubernetes's default terminationGracePeriodSeconds is 30 seconds — match your timeout to it.

Key Takeaways

  • with_graceful_shutdown(future) stops new connections when the future resolves and drains in-flight requests
  • tokio::signal::ctrl_c() and signal::unix::signal(SIGTERM) catch OS signals
  • tokio::select! waits for whichever signal arrives first
  • Always close the database pool after serve returns to cleanly drain queries
  • Add a shutdown timeout matching Kubernetes's terminationGracePeriodSeconds to avoid hanging forever

🎁 Your service is production-ready. But it's running on your laptop. Next up: packaging it in Docker and deploying it with environment-based configuration.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy