Docs / Programming & Development / Deploy Rust with Axum

Deploy Rust with Axum

By Admin · Mar 15, 2026 · Updated Apr 23, 2026 · 263 views · 5 min read

Axum is a modern Rust web framework built on top of Tower and Hyper, developed by the Tokio team. It features a type-safe, ergonomic API that leverages Rust's type system for compile-time correctness. If you prefer a more modular, middleware-composable approach compared to Actix-Web, Axum is an excellent choice for production web services.

Project Setup

# Create new project
cargo new axum-api && cd axum-api

# Cargo.toml dependencies
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace", "compression-gzip"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "macros"] }
dotenvy = "0.15"
thiserror = "1"
uuid = { version = "1", features = ["v4", "serde"] }

Application Code

// src/main.rs
use axum::{
    extract::{Path, State, Json},
    http::StatusCode,
    response::IntoResponse,
    routing::{get, post},
    Router,
};
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions;
use std::sync::Arc;
use tower_http::{cors::CorsLayer, trace::TraceLayer, compression::CompressionLayer};

#[derive(Clone)]
struct AppState {
    db: sqlx::PgPool,
}

#[derive(Serialize)]
struct User {
    id: uuid::Uuid,
    name: String,
    email: String,
}

#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}

// Custom error type
#[derive(thiserror::Error, Debug)]
enum AppError {
    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),
    #[error("Not found")]
    NotFound,
}

impl IntoResponse for AppError {
    fn into_response(self) -> axum::response::Response {
        let (status, message) = match self {
            AppError::Database(e) => {
                tracing::error!("Database error: {:?}", e);
                (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
            }
            AppError::NotFound => (StatusCode::NOT_FOUND, "Resource not found".to_string()),
        };
        (status, Json(serde_json::json!({"error": message}))).into_response()
    }
}

async fn health() -> impl IntoResponse {
    Json(serde_json::json!({"status": "healthy"}))
}

async fn list_users(State(state): State) -> Result {
    let users = sqlx::query_as!(User, "SELECT id, name, email FROM users ORDER BY name")
        .fetch_all(&state.db)
        .await?;
    Ok(Json(users))
}

async fn get_user(
    State(state): State,
    Path(id): Path,
) -> Result {
    let user = sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = $1", id)
        .fetch_optional(&state.db)
        .await?
        .ok_or(AppError::NotFound)?;
    Ok(Json(user))
}

async fn create_user(
    State(state): State,
    Json(input): Json,
) -> Result {
    let user = sqlx::query_as!(
        User,
        "INSERT INTO users (id, name, email) VALUES ($1, $2, $3) RETURNING id, name, email",
        uuid::Uuid::new_v4(), input.name, input.email
    )
    .fetch_one(&state.db)
    .await?;
    Ok((StatusCode::CREATED, Json(user)))
}

#[tokio::main]
async fn main() {
    dotenvy::dotenv().ok();
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "axum_api=info,tower_http=info".into()),
        )
        .init();

    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL required");
    let pool = PgPoolOptions::new()
        .max_connections(20)
        .connect(&database_url)
        .await
        .expect("Failed to connect to database");

    let state = AppState { db: pool };

    let app = Router::new()
        .route("/health", get(health))
        .route("/api/users", get(list_users).post(create_user))
        .route("/api/users/:id", get(get_user))
        .layer(TraceLayer::new_for_http())
        .layer(CompressionLayer::new())
        .layer(CorsLayer::permissive())
        .with_state(state);

    let addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".into());
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
    tracing::info!("Listening on {}", addr);

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

async fn shutdown_signal() {
    tokio::signal::ctrl_c().await.expect("Install Ctrl+C handler");
    tracing::info!("Shutting down gracefully...");
}

Middleware and Extractors

// Custom middleware for request timing
use axum::middleware::{self, Next};
use axum::http::Request;
use std::time::Instant;

async fn timing_middleware(
    request: Request,
    next: Next,
) -> impl IntoResponse {
    let start = Instant::now();
    let method = request.method().clone();
    let uri = request.uri().clone();

    let response = next.run(request).await;

    let duration = start.elapsed();
    tracing::info!("{} {} - {:?} - {}", method, uri, duration, response.status());

    response
}

// Add to router
let app = Router::new()
    .route("/api/users", get(list_users))
    .layer(middleware::from_fn(timing_middleware));

Deployment

# Build optimized release binary
cargo build --release

# Cargo.toml profile
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
strip = true

# Deploy binary + .env
scp target/release/axum-api user@vps:/opt/myapp/
scp .env user@vps:/opt/myapp/

# Systemd service (same pattern as Actix-Web)
# /etc/systemd/system/axum-api.service
[Unit]
Description=Axum API
After=network.target postgresql.service

[Service]
Type=simple
User=appuser
ExecStart=/opt/myapp/axum-api
EnvironmentFile=/opt/myapp/.env
Restart=always
RestartSec=3
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

Docker Deployment

# Multi-stage Dockerfile
FROM rust:1.79-alpine AS builder
RUN apk add musl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release --target x86_64-unknown-linux-musl

FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/axum-api /
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 65534
EXPOSE 8080
ENTRYPOINT ["/axum-api"]

# Build and run
docker build -t axum-api .
docker run -p 8080:8080 --env-file .env axum-api

Testing

// src/main.rs — add test module
#[cfg(test)]
mod tests {
    use super::*;
    use axum::http::StatusCode;
    use axum_test::TestServer;

    #[tokio::test]
    async fn test_health() {
        let app = Router::new().route("/health", get(health));
        let server = TestServer::new(app).unwrap();

        let response = server.get("/health").await;
        response.assert_status(StatusCode::OK);
        response.assert_json(&serde_json::json!({"status": "healthy"}));
    }
}

Axum vs Actix-Web

FeatureAxumActix-Web
RuntimeTokio (official)Actix runtime (Tokio-based)
MiddlewareTower ecosystemOwn middleware system
Type SafetyCompile-time extractorsRuntime extraction
PerformanceExcellentSlightly faster in raw benchmarks
EcosystemTower/Hyper compatibleActix-specific
Learning CurveModerateModerate

Summary

Axum provides an elegant, type-safe web framework that integrates seamlessly with the Tokio ecosystem. Its use of Tower middleware means you can compose functionality from a rich ecosystem of existing middleware. Deployment is identical to any Rust application — compile a single binary, deploy with systemd, and reverse proxy with Nginx. For teams already using Tower or Hyper, Axum is the natural choice for building production web services.

Was this article helpful?