Axum #

Axum is a web framework for Rust built on Tokio and Tower. With its focus on type safety, composability, and performance, Axum is a top choice for microservices and high-performance APIs in the modern Rust ecosystem. Unlike Actix Web, which has its own runtime, Axum fully uses Tokio — making it interoperable with any async Rust library.

Docker Compose complements Axum significantly because Rust is notorious for heavy compile times and native dependencies that often conflict. This article covers a Docker Compose setup for Axum local development, from multi-stage Dockerfiles, cargo-watch hot reload, to async database integration and best practices.

Prerequisites #

Make sure you have installed:

  • Docker and Docker Compose (latest versions)
  • The Rust toolchain (optional, for host-side development) — install via rustup
  • Git

A standard Axum project structure:

my-axum-app/
├── src/
│   ├── main.rs
│   ├── lib.rs              # optional, for library crates
│   ├── routes/
│   │   ├── mod.rs
│   │   ├── health.rs
│   │   └── users.rs
│   ├── handlers/
│   │   ├── mod.rs
│   │   └── user.rs
│   ├── models/
│   │   ├── mod.rs
│   │   └── user.rs
│   ├── db/
│   │   ├── mod.rs
│   │   └── pool.rs
│   └── error.rs
├── Cargo.toml
├── Cargo.lock
├── Dockerfile
├── Dockerfile.dev
├── docker-compose.yml
├── .env
├── .dockerignore
└── .cargo/
    └── config.toml          # cargo configuration for Docker

Splitting into routes/, handlers/, models/, and db/ is a common pattern for mid-scale Axum projects. For small services, a flat structure in main.rs alone is enough.

Multi-Stage Dockerfile #

Rust compile times are notoriously heavy. A multi-stage build separates the builder (needs the toolchain) from the runtime (just the binary).

# syntax=docker/dockerfile:1.6
FROM rust:1.75-slim AS builder

WORKDIR /app

# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    pkg-config \
    libssl-dev \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# Dummy build to cache dependencies
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs \
    && cargo build --release \
    && rm -rf src target/release/deps/axum_app*

# Real build
COPY . .
RUN cargo build --release

# Second stage: a slim runtime
FROM debian:12-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
    libssl3 \
    curl \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Non-root user
RUN addgroup -S app && adduser -S app -G app
USER app:app

COPY --from=builder /app/target/release/axum-app /app/axum-app

EXPOSE 3000

ENTRYPOINT ["/app/axum-app"]

The dummy build pattern is a key Rust trick: build an empty binary first, so all dependencies get compiled. Then replace with the real source and build again — the dependency part doesn’t need recompiling, only the application code.

The final image is based on debian:12-slim (~80 MB) or even scratch (~20 MB) for the slimmest images.

A Development Dockerfile #

For development, a single stage with cargo-watch.

# Dockerfile.dev
FROM rust:1.75-slim

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    pkg-config \
    libssl-dev \
    ca-certificates \
    git \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Install cargo-watch
RUN cargo install cargo-watch

# Cache dependencies
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs \
    && cargo fetch

# Source code is mounted via a volume

EXPOSE 3000

CMD ["cargo", "watch", "-x", "run"]

.cargo/config.toml for Build Optimization #

A .cargo/config.toml file for tuning the compiler inside containers:

# .cargo/config.toml
[net]
git-fetch-with-cli = true

[build]
# Use mold for faster linking (if available)
# rustc-wrapper = "sccache"
# Use more parallel jobs
jobs = 4

Or use sccache to cache compile artifacts across builds:

[build]
rustc-wrapper = "/usr/local/bin/sccache"

sccache stores compile results on disk, making rebuilds much faster.

docker-compose.yml #

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: axum-app:dev
    container_name: axum-api
    command: cargo watch -x run
    volumes:
      - ./src:/app/src
      - ./Cargo.toml:/app/Cargo.toml
      - ./Cargo.lock:/app/Cargo.lock
      - cargo-registry:/usr/local/cargo/registry
      - cargo-target:/app/target
    ports:
      - "3000:3000"
    environment:
      - RUST_LOG=debug
      - DATABASE_URL=postgres://app:pass@db:5432/axumapp
      - REDIS_URL=redis://cache:6379/0
      - JWT_SECRET=local-dev-secret-change-me
      - SERVER_PORT=3000
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    container_name: axum-db
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=dev
      - POSTGRES_DB=axumapp
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d axumapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    container_name: axum-cache
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    volumes:
      - cache-data:/data
    ports:
      - "6379:6379"

volumes:
  db-data:
  cache-data:
  cargo-registry:
  cargo-target:

The cargo-registry and cargo-target volumes are critical — Rust compile times are heavy, and without cache volumes, every container restart re-downloads and recompiles all dependencies. With volumes, the first container build is slow, but subsequent ones take only a few seconds.

An Example Axum Application #

Cargo.toml:

[package]
name = "axum-app"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "cors", "request-id", "util"] }
hyper = "1"

serde = { version = "1", features = ["derive"] }
serde_json = "1"

sqlx = { version = "0.7", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "macros"] }

redis = { version = "0.25", features = ["tokio-comp", "connection-manager"] }

uuid = { version = "1", features = ["serde", "v4"] }
chrono = { version = "0.4", features = ["serde"] }

tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

thiserror = "1"
anyhow = "1"

bcrypt = "0.15"
jsonwebtoken = "9"

[dev-dependencies]
reqwest = { version = "0.12", features = ["json"] }

src/main.rs:

use axum::{routing::{get, post}, Router};
use std::net::SocketAddr;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

mod error;
mod handlers;
mod models;
mod routes;

use error::AppError;
use routes::{health, users};

#[derive(Clone)]
pub struct AppState {
    pub db: sqlx::PgPool,
    pub redis: redis::aio::ConnectionManager,
    pub jwt_secret: String,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Logging
    tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "axum_app=debug,tower_http=debug,info".into()),
        )
        .with(tracing_subscriber::fmt::layer())
        .init();

    // Load environment
    let database_url = std::env::var("DATABASE_URL")?;
    let redis_url = std::env::var("REDIS_URL")?;
    let jwt_secret = std::env::var("JWT_SECRET")?;
    let port: u16 = std::env::var("SERVER_PORT")
        .unwrap_or_else(|_| "3000".into())
        .parse()?;

    // Database pool with retry
    let db = sqlx::postgres::PgPoolOptions::new()
        .max_connections(10)
        .acquire_timeout(std::time::Duration::from_secs(5))
        .connect(&database_url)
        .await?;

    // Run migrations
    sqlx::migrate!("./migrations").run(&db).await?;
    tracing::info!("database migrations applied");

    // Redis connection manager
    let redis_client = redis::Client::open(redis_url)?;
    let redis = redis::aio::ConnectionManager::new(redis_client).await?;
    tracing::info!("redis connected");

    // State
    let state = AppState { db, redis, jwt_secret };

    // Router
    let app = Router::new()
        .route("/health", get(health::healthz))
        .route("/readyz", get(health::readyz))
        .nest("/api/v1/users", users::router())
        .layer(TraceLayer::new_for_http())
        .layer(CorsLayer::permissive())
        .with_state(state);

    // Server
    let addr = SocketAddr::from(([0, 0, 0, 0], port));
    tracing::info!("listening on {}", addr);
    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;

    Ok(())
}

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

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

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

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

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

src/error.rs:

use axum::{http::StatusCode, response::IntoResponse, Json};
use serde_json::json;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("not found")]
    NotFound,

    #[error("validation error: {0}")]
    Validation(String),

    #[error("unauthorized")]
    Unauthorized,

    #[error("conflict: {0}")]
    Conflict(String),

    #[error("database error: {0}")]
    Database(#[from] sqlx::Error),

    #[error("redis error: {0}")]
    Redis(#[from] redis::RedisError),

    #[error("jwt error: {0}")]
    Jwt(#[from] jsonwebtoken::errors::Error),

    #[error("bcrypt error: {0}")]
    Bcrypt(#[from] bcrypt::BcryptError),

    #[error("internal error: {0}")]
    Internal(#[from] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> axum::response::Response {
        let (status, message) = match &self {
            AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
            AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
            AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
            _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string()),
        };

        if status == StatusCode::INTERNAL_SERVER_ERROR {
            tracing::error!(error = ?self, "internal error");
        }

        (status, Json(json!({ "error": message }))).into_response()
    }
}

src/routes/mod.rs:

pub mod health;
pub mod users;

src/routes/health.rs:

use axum::{extract::State, http::StatusCode, Json};
use serde_json::{json, Value};

use crate::AppState;

pub async fn healthz() -> (StatusCode, Json<Value>) {
    (StatusCode::OK, Json(json!({"status": "ok"})))
}

pub async fn readyz(State(state): State<AppState>) -> (StatusCode, Json<Value>) {
    let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
    let redis_ok = {
        let mut conn = state.redis.clone();
        redis::cmd("PING").query_async::<_, String>(&mut conn).await.is_ok()
    };

    if db_ok && redis_ok {
        (StatusCode::OK, Json(json!({"status": "ready", "database": "up", "cache": "up"})))
    } else {
        (
            StatusCode::SERVICE_UNAVAILABLE,
            Json(json!({"status": "degraded", "database": db_ok, "cache": redis_ok})),
        )
    }
}

src/routes/users.rs:

use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::{error::AppError, handlers::user, AppState};

pub fn router() -> Router<AppState> {
    Router::new()
        .route("/", post(create_user).get(list_users))
        .route("/:id", get(get_user).put(update_user).delete(delete_user))
        .route("/me", get(get_me))
}

#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
    pub email: String,
    pub name: String,
    pub password: String,
}

#[derive(Debug, Serialize)]
pub struct UserResponse {
    pub id: Uuid,
    pub email: String,
    pub name: String,
    pub is_verified: bool,
    pub created_at: chrono::DateTime<chrono::Utc>,
}

async fn create_user(
    State(state): State<AppState>,
    Json(req): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<UserResponse>), AppError> {
    let u = user::create(&state, req.email, req.name, req.password).await?;
    Ok((StatusCode::CREATED, Json(u)))
}

async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<UserResponse>, AppError> {
    let u = user::find_by_id(&state, id).await?;
    u.map(Json).ok_or(AppError::NotFound)
}

async fn list_users(State(state): State<AppState>) -> Result<Json<Vec<UserResponse>>, AppError> {
    let users = user::list(&state).await?;
    Ok(Json(users))
}

async fn update_user(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
    Json(req): Json<UpdateUserRequest>,
) -> Result<Json<UserResponse>, AppError> {
    let u = user::update(&state, id, req).await?;
    u.map(Json).ok_or(AppError::NotFound)
}

async fn delete_user(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
    user::delete(&state, id).await?;
    Ok(StatusCode::NO_CONTENT)
}

async fn get_me(State(state): State<AppState>) -> Result<Json<UserResponse>, AppError> {
    // Implement auth middleware first
    Err(AppError::Unauthorized)
}

src/handlers/user.rs:

use uuid::Uuid;

use crate::{
    error::AppError,
    models::User,
    routes::users::{CreateUserRequest, UpdateUserRequest, UserResponse},
    AppState,
};

pub async fn create(
    state: &AppState,
    email: String,
    name: String,
    password: String,
) -> Result<UserResponse, AppError> {
    // Validation
    if password.len() < 8 {
        return Err(AppError::Validation("password too short".into()));
    }
    if !email.contains('@') {
        return Err(AppError::Validation("invalid email".into()));
    }

    // Hash the password
    let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)?;

    // Insert
    let user = sqlx::query_as!(
        User,
        r#"
        INSERT INTO users (email, name, password_hash)
        VALUES ($1, $2, $3)
        RETURNING id, email, name, is_verified, created_at, updated_at
        "#,
        email.to_lowercase(),
        name,
        hash,
    )
    .fetch_one(&state.db)
    .await
    .map_err(|e| match e {
        sqlx::Error::Database(db_err) if db_err.constraint() == Some("users_email_key") => {
            AppError::Conflict("email already registered".into())
        }
        other => AppError::Database(other),
    })?;

    Ok(to_response(&user))
}

pub async fn find_by_id(state: &AppState, id: Uuid) -> Result<Option<UserResponse>, AppError> {
    let user = sqlx::query_as!(
        User,
        r#"SELECT id, email, name, is_verified, created_at, updated_at FROM users WHERE id = $1"#,
        id,
    )
    .fetch_optional(&state.db)
    .await?;

    Ok(user.map(|u| to_response(&u)))
}

pub async fn list(state: &AppState) -> Result<Vec<UserResponse>, AppError> {
    let users = sqlx::query_as!(
        User,
        r#"SELECT id, email, name, is_verified, created_at, updated_at
           FROM users
           ORDER BY created_at DESC
           LIMIT 100"#
    )
    .fetch_all(&state.db)
    .await?;

    Ok(users.iter().map(to_response).collect())
}

pub async fn update(
    state: &AppState,
    id: Uuid,
    req: UpdateUserRequest,
) -> Result<Option<UserResponse>, AppError> {
    let user = sqlx::query_as!(
        User,
        r#"
        UPDATE users
        SET name = COALESCE($2, name),
            is_verified = COALESCE($3, is_verified)
        WHERE id = $1
        RETURNING id, email, name, is_verified, created_at, updated_at
        "#,
        id,
        req.name,
        req.is_verified,
    )
    .fetch_optional(&state.db)
    .await?;

    Ok(user.map(|u| to_response(&u)))
}

pub async fn delete(state: &AppState, id: Uuid) -> Result<bool, AppError> {
    let result = sqlx::query!("DELETE FROM users WHERE id = $1", id)
        .execute(&state.db)
        .await?;
    Ok(result.rows_affected() > 0)
}

fn to_response(user: &User) -> UserResponse {
    UserResponse {
        id: user.id,
        email: user.email.clone(),
        name: user.name.clone(),
        is_verified: user.is_verified,
        created_at: user.created_at,
    }
}

src/models/user.rs:

use uuid::Uuid;

#[derive(Debug, sqlx::FromRow)]
pub struct User {
    pub id: Uuid,
    pub email: String,
    pub name: String,
    pub is_verified: bool,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

Migrations with SQLx #

migrations/20240101000000_create_users.sql:

CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) NOT NULL UNIQUE,
    name VARCHAR(255) NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    is_verified BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_users_created_at ON users(created_at);

sqlx::migrate!("./migrations") in main.rs automatically applies migrations at startup. To generate new migrations, install sqlx-cli:

# On the host (optional)
cargo install sqlx-cli

# Generate a migration
sqlx migrate add create_users

Middleware: Trace, CORS, Request ID #

use tower_http::{cors::CorsLayer, request_id::MakeRequestUuid, trace::TraceLayer};
use axum::Router;

let app = Router::new()
    .route("/", get(handler))
    .layer(TraceLayer::new_for_http())
    .layer(CorsLayer::permissive())
    .layer(
        tower_http::request_id::SetRequestIdLayer::new(
            tower_http::request_id::HeaderName::from_static("x-request-id"),
            MakeRequestUuid,
        ),
    );

tower-http is a library providing ready-to-use middleware for Axum. CorsLayer, TraceLayer, RequestId, Compression, Timeout — all just .layer().

Build and Run #

# Build
docker compose build

# Run
docker compose up -d

# View logs
docker compose logs -f api

# View only error logs
docker compose logs -f api | grep ERROR

# Shell into the container
docker compose exec api sh

# Stop
docker compose down

Access:

  • API: http://localhost:3000
  • Health: http://localhost:3000/health
  • Ready: http://localhost:3000/readyz
  • PostgreSQL: localhost:5432
  • Redis: localhost:6379

Test:

# Create a user
curl -X POST http://localhost:3000/api/v1/users \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","name":"Galih","password":"password123"}'

# Get a user
curl http://localhost:3000/api/v1/users/<uuid>

# List users
curl http://localhost:3000/api/v1/users

When Axum Fits #

Use Axum if:
  ✓ High-performance microservices
  ✓ You need end-to-end type safety
  ✓ Async apps with many concurrent requests
  ✓ A Rust team wanting an idiomatic framework
  ✓ You need interoperability with async Rust libraries
  ✓ Public APIs with OpenAPI specs

Avoid Axum if:
  ✗ Apps with complex business logic (Rust is verbose)
  ✗ A team not yet familiar with async/Tokio
  ✗ You need high productivity (Rust is more verbose than Go/Python)
  ✗ Simple CRUD applications
  ✗ Compile times are a blocker

Axum fits services needing high throughput, type safety, and async very well. For business applications dominated by CRUD, Rust feels like overkill — Go with Gin is more productive.

Best Practices #

Cache the Cargo Registry and Target #

Rust compile times are heavy. The cargo-registry and cargo-target volumes are mandatory. Without them, every container restart re-downloads and recompiles all dependencies (10+ minutes).

Dummy Builds for Layer Caching #

The mkdir src && echo "fn main() {}" > src/main.rs && cargo build pattern caches the dependency layer. When the real source is copied, only the application code needs compiling.

Use cargo-watch for Hot Reload #

cargo watch -x run watches .rs files and rebuilds + restarts automatically. Essential DX for Rust developers.

Bind-Mount Source Code, Don’t COPY #

In development, mount source code. The cargo-target volume caches build output. For production, COPY the source into the final image.

Connection Pools for Databases #

sqlx::PgPool with appropriate max_connections (10 by default; raise for high load). The pool handle auto-reconnects.

Tracing for Observability #

Axum integrates well with the tracing ecosystem. Use TraceLayer for HTTP tracing, tracing-subscriber for output, and export to Jaeger/Honeycomb.

Error Handling with thiserror #

Use thiserror for custom error enums implementing IntoResponse. This pattern stays consistent across all handlers.

Use sqlx for Compile-time Checked Queries #

The sqlx::query! macro checks queries against the database at compile time — if a query is wrong, the build fails. Set DATABASE_URL in the build environment.

Bind the Server to 0.0.0.0 #

Important for Docker! SocketAddr::from(([0, 0, 0, 0], port)) so it’s reachable from the host.

Graceful Shutdown #

Implement shutdown_signal() with tokio::signal::ctrl_c(). Axum’s serve() with with_graceful_shutdown waits for the shutdown signal before closing.

Troubleshooting #

Very Slow Cargo Builds #

Make sure the cargo-target volume is mounted. Check that the cargo fetch cache works. Use sccache for compile caching.

Hot Reload Not Detecting #

Make sure cargo-watch is installed. Raise the delay in the configuration. Check whether include_dir covers the source directory.

Database Connection Refused #

Use a retry loop in PgPool::connect. Use depends_on: condition: service_healthy in Compose.

Port 3000 Already in Use #

lsof -i :3000
# change the port mapping

SQLx Compile Error “no such table” #

Set DATABASE_URL during compilation so sqlx::query! can check the schema. Or use sqlx::query (runtime check) for flexibility.

Cargo Watch Looping Too Fast #

Add .gitignore patterns to cargo watch --ignore:

cargo watch -x run -i target -i .git -i node_modules

Summary #

  • Axum is ideal for high-performance Rust microservices with end-to-end type safety.
  • Axum on Tokio — interoperable with any async Rust library. Unlike Actix, which has its own runtime.
  • Multi-stage Dockerfiles for production: a dummy build to cache dependencies, then the real build. The final image is < 80 MB.
  • Dockerfile.dev for development: full toolchain + cargo-watch for hot reload.
  • Mandatory volumes for cargo-registry and cargo-target — Rust compile times are heavy, caching across container restarts is a must.
  • The dummy build pattern: mkdir src && echo "fn main() {}" > src/main.rs && cargo build caches the dependency layer.
  • Cargo-watch watches .rs files, rebuilds, and restarts automatically. Essential Rust DX.
  • SQLx with compile-time checks: the sqlx::query! macro validates queries against the database schema at compile time.
  • Connection pools for Postgres (sqlx::PgPool) and Redis (ConnectionManager). Set max_connections per load.
  • Tracing for observability: TraceLayer + tracing-subscriber + Jaeger/Honeycomb.
  • Error handling with a thiserror enum implementing IntoResponse. A consistent pattern for all handlers.
  • tower-http middleware: TraceLayer, CorsLayer, RequestId, Timeout, Compression. Just .layer().
  • Bind to 0.0.0.0 — important for Docker. SocketAddr::from(([0, 0, 0, 0], port)).
  • Graceful shutdown with tokio::signal::ctrl_c() + with_graceful_shutdown().
  • Best practices: cache Cargo, dummy builds, cargo-watch, bind mounts, connection pools, tracing, thiserror, sqlx, binding 0.0.0.0.
  • Use Axum for Rust services needing type safety and async. Avoid it for CRUD-centric business apps.
  • Alternatives: Actix Web for the highest performance, Rocket for ergonomic developer experience, warp for low-level control.

← Previous: Sinatra   Next: Actix Web →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact