# axum + sqlx + tracing + tower — HTTP API Stack The canonical production HTTP service in Rust 2026. ## Cargo.toml dependencies ```toml [dependencies] axum = { version = "0.8", features = ["macros", "tracing", "ws", "multipart"] } tokio = { version = "1", features = ["full"] } tower = "0.5" tower-http = { version = "0.6", features = [ "trace", "compression-gzip", "compression-br", "timeout", "cors", "request-id", "sensitive-headers", "limit", "set-header", ] } # Errors / observability anyhow = "1" thiserror = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } color-eyre = "0.6" # Database sqlx = { version = "0.8", features = [ "runtime-tokio-rustls", "postgres", "uuid", "macros", "migrate", "json", ] } # Serialization / validation serde = { version = "1", features = ["derive"] } serde_json = "1" validator = { version = "0.18", features = ["derive"] } # Types uuid = { version = "1", features = ["v4", "v7", "serde"] } jiff = { version = "0.1", features = ["serde"] } # Config config = { version = "0.14", features = ["toml", "yaml"] } secrecy = { version = "0.10", features = ["serde"] } # OpenAPI (optional but recommended) utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } utoipa-axum = "0.1" utoipa-swagger-ui = { version = "8", features = ["axum"] } ``` ## Project structure ``` src/ main.rs # binary entry lib.rs # re-exports + app builder config.rs # Settings type + loader state.rs # AppState (shared via Arc) routes/ mod.rs # Router::new() composition health.rs users.rs middleware/ mod.rs auth.rs request_id.rs models/ mod.rs user.rs error.rs # AppError + IntoResponse impl db/ mod.rs migrations/ migrations/ # sqlx migrations ``` ## Error type ```rust // src/error.rs use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; use serde_json::json; #[derive(Debug, thiserror::Error)] pub enum AppError { #[error("not found")] NotFound, #[error("unauthorized")] Unauthorized, #[error("validation: {0}")] Validation(String), #[error("conflict: {0}")] Conflict(String), #[error("internal")] Internal(#[from] anyhow::Error), #[error("database")] Database(#[from] sqlx::Error), } impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, code, message) = match &self { AppError::NotFound => (StatusCode::NOT_FOUND, "not_found", self.to_string()), AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized", "unauthorized".into()), AppError::Validation(m) => (StatusCode::UNPROCESSABLE_ENTITY, "validation", m.clone()), AppError::Conflict(m) => (StatusCode::CONFLICT, "conflict", m.clone()), AppError::Database(e) => { tracing::error!(error = ?e, "database error"); (StatusCode::INTERNAL_SERVER_ERROR, "database", "internal".into()) } AppError::Internal(e) => { tracing::error!(error = ?e, "internal error"); (StatusCode::INTERNAL_SERVER_ERROR, "internal", "internal".into()) } }; (status, Json(json!({"error": {"code": code, "message": message}}))).into_response() } } pub type AppResult = std::result::Result; ``` Pattern: business errors return `AppResult`; the `IntoResponse` impl translates them to HTTP. `sqlx::Error` and `anyhow::Error` auto-convert via `From`. Internal-bucket errors are logged but never leak their `Debug` representation to clients. ## AppState ```rust // src/state.rs use std::sync::Arc; use sqlx::PgPool; #[derive(Clone)] pub struct AppState { pub db: PgPool, pub config: Arc, pub http: reqwest::Client, } impl AppState { pub async fn new(config: crate::config::Settings) -> anyhow::Result { let db = sqlx::postgres::PgPoolOptions::new() .max_connections(config.db.max_connections) .acquire_timeout(std::time::Duration::from_secs(3)) .connect(config.db.url.expose_secret()) .await?; sqlx::migrate!("./migrations").run(&db).await?; let http = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) .build()?; Ok(Self { db, config: Arc::new(config), http }) } } ``` ## Route handler ```rust // src/routes/users.rs use axum::{ extract::{Path, State}, Json, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use validator::Validate; use crate::{error::{AppError, AppResult}, state::AppState}; #[derive(Debug, Deserialize, Validate)] pub struct CreateUser { #[validate(email)] pub email: String, #[validate(length(min = 1, max = 100))] pub name: String, } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct User { pub id: Uuid, pub email: String, pub name: String, pub created_at: jiff::Timestamp, } #[tracing::instrument(skip(state, body))] pub async fn create_user( State(state): State, Json(body): Json, ) -> AppResult<(axum::http::StatusCode, Json)> { body.validate().map_err(|e| AppError::Validation(e.to_string()))?; let id = Uuid::now_v7(); let user = sqlx::query_as!( User, r#"INSERT INTO users (id, email, name, created_at) VALUES ($1, $2, $3, NOW()) RETURNING id, email, name, created_at as "created_at: jiff::Timestamp""#, id, body.email, body.name ) .fetch_one(&state.db) .await .map_err(|e| match &e { sqlx::Error::Database(db) if db.code().as_deref() == Some("23505") => AppError::Conflict("email already exists".into()), _ => AppError::Database(e), })?; Ok((axum::http::StatusCode::CREATED, Json(user))) } #[tracing::instrument(skip(state))] pub async fn get_user( State(state): State, Path(id): Path, ) -> AppResult> { sqlx::query_as!( User, r#"SELECT id, email, name, created_at as "created_at: jiff::Timestamp" FROM users WHERE id = $1"#, id ) .fetch_optional(&state.db) .await? .map(Json) .ok_or(AppError::NotFound) } ``` ## Router assembly ```rust // src/routes/mod.rs use axum::{routing::{get, post}, Router}; use tower_http::{ compression::CompressionLayer, cors::CorsLayer, request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}, sensitive_headers::SetSensitiveHeadersLayer, timeout::TimeoutLayer, trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}, }; use std::time::Duration; use crate::state::AppState; mod health; mod users; pub fn router(state: AppState) -> Router { let api = Router::new() .route("/health", get(health::handler)) .route("/users", post(users::create_user)) .route("/users/:id", get(users::get_user)) .with_state(state); Router::new() .nest("/api/v1", api) .layer( tower::ServiceBuilder::new() .layer(SetSensitiveHeadersLayer::new([ axum::http::header::AUTHORIZATION, axum::http::header::COOKIE, ])) .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid)) .layer( TraceLayer::new_for_http() .make_span_with(DefaultMakeSpan::new().include_headers(false)) .on_response(DefaultOnResponse::new().latency_unit(tower_http::LatencyUnit::Millis)), ) .layer(PropagateRequestIdLayer::x_request_id()) .layer(TimeoutLayer::new(Duration::from_secs(30))) .layer(CompressionLayer::new()) .layer(CorsLayer::permissive()), // tighten in production ) } ``` Order matters: outermost layer wraps the request first. Trace before timeout so timeouts get logged. Compression after trace so trace sees the original body size. ## Main + graceful shutdown ```rust // src/main.rs use my_app::{config::Settings, routes, state::AppState}; #[tokio::main] async fn main() -> color_eyre::Result<()> { color_eyre::install()?; init_tracing(); let config = Settings::load()?; let state = AppState::new(config.clone()).await?; let app = routes::router(state); let listener = tokio::net::TcpListener::bind(&config.bind).await?; tracing::info!(addr = %config.bind, "listening"); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; Ok(()) } fn init_tracing() { use tracing_subscriber::{fmt, EnvFilter}; let filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,hyper=warn,tower_http=info")); fmt().with_env_filter(filter).with_target(false).json().init(); } async fn shutdown_signal() { let ctrl_c = async { tokio::signal::ctrl_c().await.expect("ctrl_c handler"); }; #[cfg(unix)] let terminate = async { tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) .expect("signal handler").recv().await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { _ = ctrl_c => {}, _ = terminate => {} } tracing::info!("shutting down"); } ``` ## Middleware: bearer auth example ```rust // src/middleware/auth.rs use axum::{ extract::{Request, State}, http::header::AUTHORIZATION, middleware::Next, response::Response, }; use crate::{error::AppError, state::AppState}; #[derive(Clone, Debug)] pub struct AuthUser { pub id: uuid::Uuid } pub async fn require_auth( State(state): State, mut request: Request, next: Next, ) -> Result { let token = request.headers() .get(AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|s| s.strip_prefix("Bearer ")) .ok_or(AppError::Unauthorized)?; let claims = verify_jwt(token, &state.config.jwt_secret)?; request.extensions_mut().insert(AuthUser { id: claims.sub }); Ok(next.run(request).await) } ``` Apply with `.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))` on the subroutes that need it. ## Testing handlers ```rust // tests/users.rs #[tokio::test] async fn creates_user() { let pool = test_db().await; // helper that spins up a transactional DB let state = AppState::new_test(pool).await.unwrap(); let app = my_app::routes::router(state); let request = axum::http::Request::builder() .uri("/api/v1/users") .method("POST") .header("content-type", "application/json") .body(axum::body::Body::from( serde_json::to_vec(&serde_json::json!({"email": "a@b.com", "name": "A"})).unwrap() )).unwrap(); let response = tower::ServiceExt::oneshot(app, request).await.unwrap(); assert_eq!(response.status(), 201); let bytes = axum::body::to_bytes(response.into_body(), 1 << 20).await.unwrap(); let user: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(user["email"], "a@b.com"); } ``` `tower::ServiceExt::oneshot` calls the router directly without binding a socket. Tests run in parallel without port collisions. ## Config ```rust // src/config.rs use secrecy::{Secret, ExposeSecret}; #[derive(Debug, Clone, serde::Deserialize)] pub struct Settings { pub bind: String, pub db: Database, pub jwt_secret: Secret, } #[derive(Debug, Clone, serde::Deserialize)] pub struct Database { pub url: Secret, pub max_connections: u32, } impl Settings { pub fn load() -> anyhow::Result { let cfg = config::Config::builder() .add_source(config::File::with_name("config/default").required(false)) .add_source(config::File::with_name(&format!( "config/{}", std::env::var("APP_ENV").unwrap_or_else(|_| "dev".into()) )).required(false)) .add_source(config::Environment::with_prefix("APP").separator("__")) .build()?; Ok(cfg.try_deserialize()?) } } ``` `Secret` from the `secrecy` crate hides the value in `Debug`/`Display` to prevent accidental log leakage. Access via `.expose_secret()` only where needed. ## OpenAPI (optional) Add `utoipa` derive macros on your DTOs and handlers, mount Swagger UI at `/swagger-ui`: ```rust use utoipa::OpenApi; use utoipa_axum::router::OpenApiRouter; use utoipa_swagger_ui::SwaggerUi; #[derive(OpenApi)] #[openapi( paths(routes::users::create_user, routes::users::get_user), components(schemas(routes::users::User, routes::users::CreateUser)) )] struct ApiDoc; let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) .routes(utoipa_axum::routes!(routes::users::create_user, routes::users::get_user)) .split_for_parts(); let app = router.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api)); ``` ## Production checklist - Bind to `0.0.0.0` in containers, `127.0.0.1` for local-only services. - Set `RUST_LOG=info,sqlx=warn` (or use `EnvFilter` defaults as shown). - Send logs to stdout in JSON. Ingest via Vector / Fluent Bit / Loki. - Run migrations on startup (`sqlx::migrate!` block). Fail fast on schema mismatch. - Health endpoint **must hit the DB** (so load balancers know if the pool is dead). - Add `tower::limit::RateLimitLayer` or token-bucket middleware for public endpoints. - Set `tower_http::limit::RequestBodyLimitLayer` to bound request size. - Compress with brotli + gzip via `CompressionLayer`. - Tighten CORS, do not ship `CorsLayer::permissive()` to production. - Strip sensitive headers from traces via `SetSensitiveHeadersLayer`. - Set up SIGTERM-driven `with_graceful_shutdown` so deploys roll without dropping requests. - Containerize with `cargo chef` for incremental Docker builds. ## Common mistakes 1. **Forgetting `error_for_status()?` on outbound `reqwest`** — 4xx silently succeeds. 2. **Returning `Result` from handlers** — leak DB details to clients. Always go through `AppError`. 3. **`Json` extractor before validation** — invalid JSON returns axum's default 422 with no body shape. Wrap in a `ValidatedJson` extractor that runs `validator` and returns `AppError`. 4. **Holding DB connections across `.await` on slow external calls** — exhausts the pool. Acquire late, release early. 5. **Skipping `tracing::instrument`** on handlers — losing per-request span correlation. 6. **No `RequestBodyLimitLayer`** — DoS surface. Default axum has no limit.