Getting Started
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against axum 0.8.9 on Rust 1.99 (edition 2024; axum itself needs Rust 1.80 or later), with tokio
1.53, checked on 2026-10-06. It creates the bookshelf-web crate that every page of this section extends. If you have
not installed Rust yet, follow Rust: Getting Started first.
Where it runs in the request
In the request lifecycle (tokio accepts
the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and
extractors run, the handler runs, the response travels back out), the first service on this page touches the essential
steps once: tokio::main starts the runtime, axum::serve drives hyper, the Router matches GET /health, the
handler runs and its &'static str becomes a 200 OK text response. There is no middleware and no extractor yet;
extractors appear in the compile-error example at the end of the page, and layers on later pages, between hyper and
the router or around the matched route.
Creating the crate
Bookshelf lives in the same Cargo workspace as the shelf-core domain crate from the
Rust Reference. From the workspace root:
$ cargo new --bin crates/bookshelf-web
$ cd crates/bookshelf-web
$ cargo add axum@0.8.9
$ cargo add tokio@1.53 --features full
$ cargo add serde@1 --features derive
$ cargo add tracing@0.1 tracing-subscriber@0.3
The resulting manifest:
[package]
name = "bookshelf-web"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
[dependencies]
axum = "0.8.9"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1.53", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.3"
axum’s documentation lists its required dependencies: axum itself and tokio. You can trim tokio’s features to
["macros", "rt-multi-thread", "net"] (plus signal for graceful shutdown, time for timers, sync for channels)
once the service stabilises; full is convenient while learning. tower and tower-http are added when the first
middleware is (see tower-http Catalog).
Source: axum: Required dependencies
Feature flags
| Feature | Default | Enables |
|---|---|---|
|
yes |
HTTP/1.1 support in |
|
no |
HTTP/2 (h2c and TLS-negotiated); needed for WebSockets over HTTP/2 |
|
yes |
the |
|
yes |
the |
|
yes |
the |
|
yes |
the |
|
yes |
the |
|
yes |
|
|
yes |
the |
|
yes |
internal |
|
no |
|
|
no |
WebSockets ( |
|
no |
the |
|
no |
internal; never enable |
A pure-library crate that only defines extractors can depend on axum-core with no features; an application usually
adds macros, and ws/multipart only when it uses them, to keep compile times down.
Source: axum: Feature flags
The first service
use axum::{Router, routing::get};
async fn health() -> &'static str {
"ok"
}
fn app() -> Router {
Router::new().route("/health", get(health))
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.expect("failed to bind port 3000");
tracing::info!("listening on {}", listener.local_addr().expect("bound socket has an address"));
axum::serve(listener, app()).await.expect("server error");
}
Source: axum::serve
Line by line:
-
#[tokio::main]turnsasync fn maininto a synchronousmainthat builds a multi-thread tokio runtime and blocks on the future. It is shorthand fortokio::runtime::Builder::new_multi_thread().enable_all().build()?.block_on(...)(see Async Runtimes & Tokio). -
tokio::net::TcpListener::bindopens a non-blocking listening socket. Bind0.0.0.0(all interfaces) inside a container, where the port is published by the runtime; bind127.0.0.1on a laptop to keep the service private. Binding port0asks the OS for a free port, which tests use (listener.local_addr()reports it). -
axum::serve(listener, app)accepts connections forever, spawning one task per connection, and returns aServefuture that only completes on an I/O error — or when graceful shutdown is configured with.with_graceful_shutdown(signal)(see Graceful Shutdown). -
Router::new().route("/health", get(health))registers an async function as the handler forGET /health.getbuilds aMethodRouter; chaining.post(...)on it adds more methods for the same path (see Routing). -
A
&'static strreturn value becomes a200 OKresponse withcontent-type: text/plain; charset=utf-8.
axum::serve replaced axum::Server (a re-export of hyper 0.14’s server) in axum 0.7. It is intentionally simple: no
TLS, no per-connection configuration. When you need those, either put a TLS-terminating proxy in front (the usual
production choice) or drive hyper-util yourself (see TLS & HTTPS and
Performance Tuning).
$ cargo run
Compiling bookshelf-web v0.1.0 (/work/shelf/crates/bookshelf-web)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.81s
Running `target/debug/bookshelf-web`
2026-10-06T09:12:44.512881Z INFO bookshelf_web: listening on 0.0.0.0:3000
$ curl -i localhost:3000/health
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 2
date: Tue, 06 Oct 2026 09:12:51 GMT
ok
$ curl -i localhost:3000/nope
HTTP/1.1 404 Not Found
content-length: 0
date: Tue, 06 Oct 2026 09:12:58 GMT
$ curl -i -X POST localhost:3000/health
HTTP/1.1 405 Method Not Allowed
allow: GET,HEAD
content-length: 0
date: Tue, 06 Oct 2026 09:13:02 GMT
Source: axum::serve
The last two responses come from axum, not from your code: unmatched paths go to the router’s default fallback
(404), and a matched path with an unsupported method gets 405 with an Allow header. A GET route also answers
HEAD requests automatically, with the body stripped.
Configuring the address and port
Hard-coding 3000 is fine for a first run, but a deployable service reads its address from the environment (the full
treatment is on the Configuration page):
use std::net::SocketAddr;
use axum::{Router, routing::get};
#[tokio::main]
async fn main() {
let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(3000);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let app = Router::new().route("/health", get(|| async { "ok" }));
let listener = tokio::net::TcpListener::bind(addr).await.expect("failed to bind");
axum::serve(listener, app).await.expect("server error");
}
Source: tokio::net::TcpListener::bind
The closure || async { "ok" } is a valid handler too: any Clone + Send + Sync + 'static function or closure returning
a Send future whose output implements IntoResponse qualifies (see Handlers).
Fast feedback while developing
A Rust service has to be recompiled and restarted after every change. Three tools shorten the loop:
-
bacon runs
cargo check/clippy/testcontinuously in a terminal and shows the first error; it is the quickest way to get compiler feedback while editing.bacon runrestarts the binary on success. -
cargo-watch re-runs a cargo command on file changes (
cargo watch -x run). Its maintainers consider it complete and point new users to bacon orwatchexec; it still works. -
listenfd + systemfd keep the listening socket open across restarts, so clients (and your browser tab) never see "connection refused" while the new binary starts.
systemfdopens the socket and passes it to the child process; the service takes it over withlistenfdand falls back to binding itself when run normally.
$ cargo install bacon systemfd cargo-watch
$ bacon clippy # continuous lint feedback
$ systemfd --no-pid -s http::3000 -- cargo watch -x run # auto-reload, socket kept open
Source: tokio::net::TcpListener::bind
use axum::{Router, routing::get};
use listenfd::ListenFd;
use tokio::net::TcpListener;
#[tokio::main]
async fn main() {
let app = Router::new().route("/health", get(|| async { "ok" }));
let mut listenfd = ListenFd::from_env();
let listener = match listenfd.take_tcp_listener(0).expect("invalid LISTEN_FDS") {
// Started by systemfd: reuse the already-open socket.
Some(std_listener) => {
std_listener.set_nonblocking(true).expect("cannot set non-blocking");
TcpListener::from_std(std_listener).expect("cannot adopt socket")
}
// Started normally: bind as usual.
None => TcpListener::bind("127.0.0.1:3000").await.expect("failed to bind"),
};
axum::serve(listener, app).await.expect("server error");
}
Source: axum example: auto-reload
The hello-world and readme examples in the axum repository are the same minimal service as above.
Reading axum’s compiler errors
axum checks handlers with traits, so a mistake in a handler signature is reported where the handler is used
(get(show_book)), not where it is defined, and the message names the Handler trait rather than the offending
parameter:
use axum::{Router, routing::get};
async fn show_book(id: u32) -> String { // u32 is not an extractor
format!("book {id}")
}
fn main() {
let _app: Router = Router::new().route("/books/{id}", get(show_book));
// error[E0277]: the trait bound `fn(u32) -> impl Future<Output = String> {show_book}: Handler<_, _>`
// is not satisfied
// = note: Consider using `#[axum::debug_handler]` to improve the error message
}
E0277 means "a trait bound is not satisfied". Adding
#[axum::debug_handler] (with the macros feature) above the function makes axum generate one check per parameter
and for the return type, so the error points at the real problem:
error[E0277]: the trait bound `u32: FromRequest<(), axum_core::extract::private::ViaParts>` is not satisfied
--> src/main.rs:4:24
|
4 | async fn show_book(id: u32) -> String {
| ^^^ the trait `FromRequestParts<()>` is not implemented for `u32`
|
= note: Function argument is not a valid axum extractor.
See `https://docs.rs/axum/0.8/axum/extract/index.html` for details
Source: Rust error codes: E0277 and axum: debug_handler
The fix here is Path(id): Path<u32>. The other usual causes — a body extractor that is not the last parameter, a
non-Send value held across .await, a return type that does not implement IntoResponse, more than 16 parameters,
or a state type that does not match the router’s — are explained, with their messages, on the
Handlers and Extractors pages. debug_handler
has no runtime cost, but it is common to add it only while fixing an error.
Where the books are outdated
Yoon (Beginning Axum, chapter 1) creates the project with edition 2021; new crates default to edition 2024 since Cargo
1.85, which changes nothing in this code but enables let chains and the newer temporary-scope rules used on later
pages. Older tutorials start the server with axum::Server::bind(&addr).serve(app.into_make_service()); that API was
removed in axum 0.7 in favour of axum::serve with a tokio TcpListener.