Skip to content
rusthacks.dev
Web

An Axum Service You Can Run in Production From Zero

Build a small Axum 0.8 service from an empty crate to something you can deploy: a testable router, one error type, limits and timeouts, tracing, oneshot tests and graceful shutdown on SIGTERM.

17 min read
Summarize in AI
Flat illustration for the post “An Axum Service You Can Run in Production From Zero”, composed around deploy.

A hello world in Axum is eight lines. A service that survives a deploy, a slow client and a malformed body is closer to two hundred, and almost none of those lines are routes. This guide builds the second kind in one crate, a tiny notes API, and every block below is the code that was compiled and run, with the output it printed. It sits at the top of the Axum cluster and assumes you know the basics of async Rust and Tokio.

Everything here was built with Rust 1.99 on edition 2024, axum 0.8.9, tokio 1.54, tower 0.5 and tower-http 0.7. Pin the same versions in Cargo.toml and you get the same output.

Start with a router you can build twice

The first decision decides how testable the service will be. Do not build the router inside main. Put it in a function that takes the state and returns a Router. main calls it with a real listener; the tests call it with nothing at all.

The dependencies for the whole guide:

Cargo.tomlTOML
[package]
name = "notes"
version = "0.1.0"
edition = "2024"

[dependencies]
axum = { version = "0.8.9", features = ["macros"] }
http-body-util = "0.1.5"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
thiserror = "2.0.21"
tokio = { version = "1.54.1", features = ["full"] }
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.7.1", features = ["trace", "timeout", "limit"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }

The crate is a library plus a thin binary, which is what lets tests import app without opening a port. The library holds the state, the handlers and the router:

src/lib.rsrust
pub mod error;

use std::{
    collections::HashMap,
    sync::{
        Arc, RwLock,
        atomic::{AtomicU64, Ordering},
    },
    time::Duration,
};

use axum::{
    Json, Router,
    extract::{FromRequest, Path, State},
    http::StatusCode,
    routing::{get, post},
};
use error::AppError;
use serde::{Deserialize, Serialize};
use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer, trace::TraceLayer};

#[derive(Clone, Default)]
pub struct AppState {
    notes: Arc<RwLock<HashMap<u64, Note>>>,
    // A counter, not `len() + 1`: deleting a note must never free its id.
    next_id: Arc<AtomicU64>,
}

#[derive(Clone, Serialize)]
pub struct Note {
    id: u64,
    title: String,
}

/// Same as `axum::Json`, but a bad body is reported as an `AppError`.
#[derive(FromRequest)]
#[from_request(via(Json), rejection(AppError))]
struct ApiJson<T>(T);

#[derive(Deserialize)]
struct NewNote {
    title: String,
}

async fn health() -> &'static str {
    "ok"
}

async fn create_note(
    State(state): State<AppState>,
    ApiJson(input): ApiJson<NewNote>,
) -> Result<(StatusCode, Json<Note>), AppError> {
    if input.title.trim().is_empty() {
        return Err(AppError::EmptyTitle);
    }
    let id = state.next_id.fetch_add(1, Ordering::Relaxed) + 1;
    let mut notes = state.notes.write().unwrap();
    let note = Note {
        id,
        title: input.title,
    };
    notes.insert(id, note.clone());
    Ok((StatusCode::CREATED, Json(note)))
}

async fn get_note(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<Json<Note>, AppError> {
    let notes = state.notes.read().unwrap();
    notes
        .get(&id)
        .cloned()
        .map(Json)
        .ok_or(AppError::NotFound(id))
}

async fn delete_note(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<StatusCode, AppError> {
    let removed = state.notes.write().unwrap().remove(&id);
    removed
        .map(|_| StatusCode::NO_CONTENT)
        .ok_or(AppError::NotFound(id))
}

pub fn app(state: AppState) -> Router {
    Router::new()
        .route("/health", get(health))
        .route("/notes", post(create_note))
        .route("/notes/{id}", get(get_note).delete(delete_note))
        .layer(RequestBodyLimitLayer::new(16 * 1024))
        .layer(TimeoutLayer::with_status_code(
            StatusCode::REQUEST_TIMEOUT,
            Duration::from_secs(10),
        ))
        .layer(TraceLayer::new_for_http())
        .with_state(state)
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{body::Body, http::Request};
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    fn json_post(uri: &str, body: &str) -> Request<Body> {
        Request::post(uri)
            .header("content-type", "application/json")
            .body(Body::from(body.to_owned()))
            .unwrap()
    }

    #[tokio::test]
    async fn create_then_fetch() {
        let app = app(AppState::default());

        let res = app
            .clone()
            .oneshot(json_post("/notes", r#"{"title":"ship it"}"#))
            .await
            .unwrap();
        assert_eq!(res.status(), StatusCode::CREATED);

        let res = app
            .oneshot(Request::get("/notes/1").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(res.status(), StatusCode::OK);
        let bytes = res.into_body().collect().await.unwrap().to_bytes();
        assert_eq!(&bytes[..], br#"{"id":1,"title":"ship it"}"#);
    }

    #[tokio::test]
    async fn deleted_ids_are_not_reused() {
        let app = app(AppState::default());
        let make = || json_post("/notes", r#"{"title":"a"}"#);

        app.clone().oneshot(make()).await.unwrap();
        let res = app
            .clone()
            .oneshot(Request::delete("/notes/1").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(res.status(), StatusCode::NO_CONTENT);

        let res = app.oneshot(make()).await.unwrap();
        let bytes = res.into_body().collect().await.unwrap().to_bytes();
        let body = String::from_utf8(bytes.to_vec()).unwrap();
        assert_eq!(body, r#"{"id":2,"title":"a"}"#);
    }

    #[tokio::test]
    async fn missing_note_is_404_json() {
        let res = app(AppState::default())
            .oneshot(Request::get("/notes/9").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(res.status(), StatusCode::NOT_FOUND);
        let bytes = res.into_body().collect().await.unwrap().to_bytes();
        assert_eq!(&bytes[..], br#"{"error":"note 9 not found"}"#);
    }

    #[tokio::test]
    async fn oversized_body_is_413() {
        let big = format!(r#"{{"title":"{}"}}"#, "x".repeat(20_000));
        let res = app(AppState::default())
            .oneshot(json_post("/notes", &big))
            .await
            .unwrap();
        assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE);
    }
}

Read it top to bottom once. AppState is Clone because Axum clones the state for every request; the actual data sits behind an Arc<RwLock<..>>, so a clone is a pointer copy, not a copy of the notes. create_note takes State first and the Json body last, which is not stylistic: an extractor that consumes the body must be the final argument, and Axum checks that at compile time. The deeper rules are in extractor order and shared state.

The route table uses "/notes/{id}". Axum 0.8 changed capture syntax from :id to {id}, and the old form is not silently accepted. If you copy an older tutorial you get this at startup, not at request time:

Text
thread 'main' panicked at src/lib.rs:77:10:
Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router.

That is a good failure: it happens before the first request, with the file and line of the offending route call. The fix is the brace syntax, and the migration notes are in the 0.8 migration post.

One error type, one place that decides status codes

Handlers that return Result<Json<T>, AppError> let you use ? and early returns without writing a response by hand. The enum and its IntoResponse impl live in their own file:

src/error.rsrust
use axum::{
    Json,
    extract::rejection::JsonRejection,
    http::StatusCode,
    response::{IntoResponse, Response},
};
use serde_json::json;

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("note {0} not found")]
    NotFound(u64),
    #[error("title must not be empty")]
    EmptyTitle,
    #[error(transparent)]
    BadJson(#[from] JsonRejection),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let status = match &self {
            AppError::NotFound(_) => StatusCode::NOT_FOUND,
            AppError::EmptyTitle => StatusCode::UNPROCESSABLE_ENTITY,
            AppError::BadJson(rejection) => rejection.status(),
        };
        (status, Json(json!({ "error": self.to_string() }))).into_response()
    }
}

Two things matter here. First, the status code is chosen in exactly one match, so adding a variant forces you, through the exhaustive match, to decide what it means over HTTP. Second, the body is JSON with a single error field built from the Display text, which thiserror derives from the #[error(...)] attributes. Clients get a consistent shape for every failure, and you never leak an internal Debug dump, which is the habit that quietly exposes file paths and query text to the internet.

The tempting shortcut is to return something that already looks like an error. String happens to implement IntoResponse, so it compiles and always answers 200 with plain text, which is worse than a failure because nothing tells you. A type that does not implement IntoResponse fails loudly. Swap AppError for std::fmt::Error in get_note and the compiler says:

Text
error[E0277]: the trait bound `fn(State<AppState>, Path<u64>) -> _ {get_note}: Handler<_, _>` is not satisfied
   --> src/lib.rs:77:35
    |
 77 |         .route("/notes/{id}", get(get_note))
    |                               --- ^^^^^^^^ the trait `Handler<_, _>` is not implemented for fn item `fn(State<AppState>, Path<u64>) -> impl Future<Output = _> {get_note}`
    |                               |
    |                               required by a bound introduced by this call
    |
    = note: Consider using `#[axum::debug_handler]` to improve the error message

The message names the handler and the route, but not the reason. The note at the bottom is the useful part: #[axum::debug_handler] on the function turns this into a message that says which argument or return type is the problem. Add it temporarily whenever a handler refuses to register. More on shaping errors is in Axum error handling.

Limits and a timeout before features

The router in lib.rs ends with three layers, and their order is deliberate. Layers wrap what was added before them, so the last .layer call is the outermost. TraceLayer is outermost, which means it sees every request including the ones the other two reject. TimeoutLayer is next and cuts any request that takes more than ten seconds, answering 408 through with_status_code. RequestBodyLimitLayer sits closest to the handlers and refuses bodies over 16 KiB with 413 before Json tries to buffer them.

Why these three first? They are the failures that arrive without any bug in your code. A client on a bad mobile connection sends a request body at 200 bytes a second. Without a timeout that task, its memory and a slot in your connection pool stay occupied for as long as the client likes. A script that posts a 2 GB body does not need to be malicious to hurt you. Both limits cost one line each.

The test oversized_body_is_413 proves the limit works without a network: it posts 20,000 bytes against a 16,384 byte limit and asserts the status. That brings us to the part most Axum tutorials skip.

Test the router, not a running server

tower::ServiceExt::oneshot consumes a service and a request and hands back the response. A Router is a Service, so the whole application can be driven in-process:

Text
test tests::oversized_body_is_413 ... ok
test tests::missing_note_is_404_json ... ok
test tests::create_then_fetch ... ok
test tests::deleted_ids_are_not_reused ... FAILED
test result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

The four tests in lib.rs build the router with app(AppState::default()), so every test gets fresh state and none can leak notes into another. Note the app.clone() in the first test: oneshot takes the router by value, and a Router is cheap to clone because the state is shared behind the Arc. The second request in that test sees the note the first one created, which is the whole point of cloning the router rather than rebuilding it.

Reading the body takes two lines of boilerplate (collect().await.unwrap().to_bytes() from http-body-util). Wrap that in a helper once you have more than a handful of tests. See testing Axum with oneshot for helpers that deserialize into a type, and integration tests with a real database for the next step up.

The speed difference matters in practice. These four tests run in well under a millisecond because there is no bind, no TCP handshake and no port to collide with when CI runs tests in parallel. A test that boots a server per case is ten to a hundred times slower and eventually flaky.

Make the extractor failures speak JSON too

AppError covers failures you raise. There is a second class you never see in a handler: the request never reached it. Send a broken body to the first version of this service and Axum answers from inside the Json extractor, in plain text, with its own status codes:

Text
# malformed json
Failed to parse the request body as JSON: title: EOF while parsing a value at line 1 column 9 [400]
# wrong field type
Failed to deserialize the JSON body into the target type: title: invalid type: integer `5`, expected a string at line 1 column 10 [422]
# no content type
Expected request with `Content-Type: application/json` [415]
# bad id
Invalid URL: Cannot parse `abc` to a `u64` [400]

Four failures, four different texts, and none of them JSON. A client that parses every error body as {"error": ...} chokes on the first one. The statuses are right (400 for syntax, 422 for a type mismatch, 415 for a missing content type), only the shape is wrong. The cure is to have the extractor report through your error type. ApiJson in lib.rs is a three line wrapper that derives FromRequest via Json and names AppError as its rejection, which is why axum has the macros feature turned on in the manifest. AppError::BadJson holds the original JsonRejection, keeps its status with rejection.status() and its message with #[error(transparent)].

Run the same three bodies again and the shape is uniform:

Text
# malformed json
{"error":"Failed to parse the request body as JSON: title: EOF while parsing a value at line 1 column 9"} [400]
# wrong field type
{"error":"Failed to deserialize the JSON body into the target type: title: invalid type: integer `5`, expected a string at line 1 column 10"} [422]
# no content type
{"error":"Expected request with `Content-Type: application/json`"} [415]

The Path rejection for a non numeric id (the fourth case above) still answers in plain text; wrap Path the same way when your clients depend on it. Do not forget what the message contains: title: invalid type: integer 5, expected a string at line 1 column 10 is useful to a developer and harmless, but a message that echoes server internals is not. Read what a rejection prints before you forward it to the world.

Start the server and look at what it actually does

main is short on purpose:

src/main.rsrust
use notes::{AppState, app};
use tokio::{net::TcpListener, signal};
use tracing_subscriber::EnvFilter;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(
            EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,tower_http=debug".into()),
        )
        .init();

    let addr = std::env::var("BIND").unwrap_or_else(|_| "127.0.0.1:3000".into());
    let listener = TcpListener::bind(&addr).await?;
    tracing::info!(%addr, "listening");

    axum::serve(listener, app(AppState::default()))
        .with_graceful_shutdown(shutdown_signal())
        .await
}

async fn shutdown_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::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
    tracing::info!("shutdown signal received, draining");
}

EnvFilter::try_from_default_env reads RUST_LOG, falling back to info,tower_http=debug so a developer who sets nothing still sees request logs. This is the line that failed to compile the first time, because the EnvFilter type lives behind a Cargo feature that cargo add tracing-subscriber does not enable:

Text
error[E0432]: unresolved import `tracing_subscriber::EnvFilter`
error[E0599]: no method named `with_env_filter` found for struct `SubscriberBuilder<N, E, F, W>` in the current scope
error: could not compile `notes` (bin "notes") due to 2 previous errors

The fix is tracing-subscriber = { version = "0.3", features = ["env-filter"] }, which is already in the manifest above. The bind address comes from the BIND environment variable so the same binary runs on a laptop and in a container where it must listen on 0.0.0.0. Binding to 127.0.0.1 by default is the safe choice: a service that listens on every interface by accident is a service somebody finds.

Start it and exercise the routes from another terminal:

Shell
cargo run
curl -i -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"title":"ship it"}'
curl -s localhost:3000/notes/1
curl -s -w ' %{http_code}\n' localhost:3000/notes/7
curl -s -w ' %{http_code}\n' -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"title":"  "}'

The first request prints:

Text
HTTP/1.1 201 Created
content-type: application/json
content-length: 26

{"id":1,"title":"ship it"}

and the other two print the error bodies with their status codes:

Text
{"error":"note 7 not found"}        404
{"error":"title must not be empty"} 422

Every error has the same {"error": "..."} shape and a status that matches its meaning. A blank title is 422 Unprocessable Entity, not a 400, because the JSON was well formed but the content failed a rule; the distinction helps clients decide between fixing syntax and fixing data.

With TraceLayer::new_for_http() each request produces a span with the method and URI, plus events when processing starts, when the response is ready and when the body stream ends. The server log from the run above, with the timestamps removed, looks like this for one request:

Text
INFO notes: listening addr=127.0.0.1:3111
DEBUG request{method=POST uri=/notes version=HTTP/1.1}: tower_http::trace::on_request: started processing request
DEBUG request{method=POST uri=/notes version=HTTP/1.1}: tower_http::trace::on_response: finished processing request latency=0 ms status=201
DEBUG request{method=POST uri=/notes version=HTTP/1.1}: tower_http::trace::on_eos: end of stream stream_duration=0 ms

Notice latency=0 ms on an in-memory store; the field is what you alert on once a real database sits behind the handler. The default level for the response event is DEBUG, so RUST_LOG=info hides these lines. That is usually what you want in production at high volume, but turn them on while you are tracking down a slow route. Correlating a log line with one request across services needs a request id, which is its own topic: request ids in Axum and structured logging with tracing.

Shut down without dropping requests

with_graceful_shutdown takes a future. When it resolves, the server stops accepting new connections and waits for in-flight requests to finish. The shutdown_signal function above races two signals with tokio::select!: Ctrl+C for development and SIGTERM for everything that deploys you.

The second one is the one people forget. Docker's docker stop, systemd and Kubernetes all send SIGTERM first and SIGKILL after a grace period (30 seconds by default in Kubernetes). A service that only listens for Ctrl+C ignores the polite signal, waits out the grace period doing nothing and is then killed with requests still running. The #[cfg(unix)] gate keeps the code compiling on Windows, where the unix signal module does not exist.

In the run above, sending SIGTERM to the process printed the last log line, shutdown signal received, draining, and the process exited cleanly. To see draining in action, add a handler that sleeps for five seconds, start a request, send the signal during it and watch the response still arrive. The complete story, including readiness probes that go red before the listener closes, is in graceful shutdown in Axum and health, readiness and liveness.

A checklist for the first deploy

Before this goes anywhere with real traffic, check these in order:

  1. cargo test and cargo clippy --all-targets -- -D warnings pass on the pinned versions.
  2. The service reads its bind address and any secrets from the environment, and refuses to start when a required value is missing, rather than defaulting to something insecure.
  3. A body limit and a timeout are set, and you know the numbers. Sixteen KiB suits a notes API; a file upload route needs a larger limit applied only to that route with route_layer.
  4. Errors map to specific statuses and never echo internal details.
  5. SIGTERM drains. Test it by hand once.
  6. A health route exists that does not touch the database, and a separate readiness check that does.

Packaging, images and rollout are in shipping Rust to production.

Add a feature the way the tests want it

The state holds a counter next to the map, and the reason is a bug the first draft of this service had. It numbered new notes with notes.len() + 1, which is fine until something deletes a note: delete note 1 of 2, create another, and the length is 1 again, so the new note is handed id 2 while the old note 2 still exists and silently gets overwritten. A client holding the URL of the deleted note would now read someone else's data.

The test deleted_ids_are_not_reused was written to catch it: create a note, delete it, create another and require id 2. Run against the len() + 1 version it fails with the real assertion output:

Text
thread 'tests::deleted_ids_are_not_reused' panicked at src/lib.rs:152:9:
assertion `left == right` failed
  left: "{\"id\":1,\"title\":\"a\"}"
 right: "{\"id\":2,\"title\":\"a\"}"

The right side says the new note should be 2; the left shows it came back as 1. The fix is the AtomicU64 in AppState, incremented with fetch_add before the write lock is taken. Ordering::Relaxed is enough because the counter only has to hand out unique values; it does not order any other memory. That is also why the id is allocated before locking: the lock protects the map, the atomic protects the counter, and neither waits on the other.

This is the loop to adopt for every feature. Write the request as a test against app(...), watch it fail for the reason you expect, then change the handler. Because the router is a plain value, the failing test is a dozen lines and runs in a millisecond, so you actually do it.

The delete_note handler shows the error flow once more: remove returns an Option, map turns a hit into 204 No Content and ok_or turns a miss into AppError::NotFound. No handler builds a response by hand, and no handler decides a status code.

What the in-memory store still hides

The store is a HashMap behind an RwLock. It loses everything on restart, and a std::sync::RwLock guard must never be held across an .await. The code avoids that only because nothing awaits while a lock is taken. The moment a handler calls a database inside that block, Clippy's await_holding_lock fires, and without it you would get a deadlock under load that no unit test reproduces.

When the store becomes a database you pass a pool in AppState instead and await queries with no lock held. Axum and SQLx together shows that step, and the same app(state) function carries it without changing a single test signature.

Where this layout is the wrong choice

If the service is a two-route webhook receiver, a library crate with three modules is more structure than it deserves; keep it in main.rs and test with oneshot against a function in the same file. If your team already runs Actix Web, the stack is mature and switching has no performance reason, as the Axum versus Actix Web comparison measures. And if you need server-rendered pages, sessions and an ORM bundled together, a larger framework removes work Axum deliberately leaves to you.

The framework's own reference is on docs.rs for axum, and its examples directory is the best place to check a pattern against the maintainers' version.

In this series

Also inRustDevOps
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.