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-registryandcargo-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 buildcaches the dependency layer.- Cargo-watch watches
.rsfiles, 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). Setmax_connectionsper load.- Tracing for observability:
TraceLayer+tracing-subscriber+ Jaeger/Honeycomb.- Error handling with a
thiserrorenum implementingIntoResponse. 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.