Building a REST API with Axum and Rust: Type-Safe Services, SQLx Database Integration, and Docker Deployment

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

The Problem with “Fast Enough” APIs

I’ve maintained Node.js and Python APIs in production long enough to know the moment you start dreading deployments. Runtime panics from undefined is not a function, database query errors that only surface under load, Docker images ballooning past 800MB. Individually, none of these is fatal. Together, they add up to a system you half-trust on a good day.

That’s what pushed me toward Rust for backend services. Not for the performance numbers — though those are real — but for the guarantee that if the code compiles, an entire class of bugs simply doesn’t exist. Axum, built on tokio and hyper, brings that guarantee to HTTP services with an API that actually fits how you think about web handlers.

This is what I learned building REST APIs with Axum and SQLx from scratch — what actually matters, what tripped me up, and what I’d tell a colleague starting today.

Core Concepts Worth Understanding First

Axum’s Extractor Model

Axum’s design is built around extractors — types that pull data out of an HTTP request. Path parameters, JSON bodies, query strings, headers — all extracted via function arguments, all validated at compile time. Declare a handler expecting a JSON body of type CreateUser, and Axum verifies that type before your server ever starts. Mismatched types are compiler errors, not 500 responses.

SQLx: Compile-Time SQL Verification

SQLx does something remarkable: it checks your SQL queries against an actual database at compile time. The macro sqlx::query_as! connects to your dev database during cargo build and validates column names, types, and query correctness. Rename a column in your schema and forget to update a query — the build fails. That kind of fast feedback has saved me from more than a few surprise production alerts.

The Tower Middleware Stack

Axum is built on Tower, a composable middleware library. CORS, rate limiting, tracing, compression — these are all Tower layers. Knowing that Axum handlers are just Tower services means you can compose behavior cleanly, without framework magic you can’t inspect or test.

Hands-On: Building a User API from Scratch

Project Setup

cargo new rust-api && cd rust-api

Add dependencies to Cargo.toml:

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-tls", "uuid", "chrono"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"

Database Schema

-- migrations/0001_create_users.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  username TEXT NOT NULL UNIQUE,
  email TEXT NOT NULL UNIQUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Run migrations with: sqlx migrate run

Application State and Models

// src/main.rs
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;

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

#[derive(Serialize, sqlx::FromRow)]
struct User {
    id: Uuid,
    username: String,
    email: String,
    created_at: chrono::DateTime<chrono::Utc>,
}

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

Handler Implementation

async fn list_users(
    State(state): State<AppState>,
) -> impl IntoResponse {
    match sqlx::query_as!(
        User,
        "SELECT id, username, email, created_at FROM users ORDER BY created_at DESC"
    )
    .fetch_all(&state.db)
    .await
    {
        Ok(users) => (StatusCode::OK, Json(users)).into_response(),
        Err(e) => {
            tracing::error!("Failed to fetch users: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
        }
    }
}

async fn create_user(
    State(state): State<AppState>,
    Json(payload): Json<CreateUser>,
) -> impl IntoResponse {
    match sqlx::query_as!(
        User,
        "INSERT INTO users (username, email) VALUES ($1, $2)
         RETURNING id, username, email, created_at",
        payload.username,
        payload.email
    )
    .fetch_one(&state.db)
    .await
    {
        Ok(user) => (StatusCode::CREATED, Json(user)).into_response(),
        Err(sqlx::Error::Database(e)) if e.constraint() == Some("users_username_key") => {
            (StatusCode::CONFLICT, "Username already taken").into_response()
        }
        Err(e) => {
            tracing::error!("Failed to create user: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
        }
    }
}

async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> impl IntoResponse {
    match sqlx::query_as!(
        User,
        "SELECT id, username, email, created_at FROM users WHERE id = $1",
        id
    )
    .fetch_optional(&state.db)
    .await
    {
        Ok(Some(user)) => (StatusCode::OK, Json(user)).into_response(),
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
        Err(e) => {
            tracing::error!("Failed to fetch user {}: {}", id, e);
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
        }
    }
}

Main Function with Connection Pool

#[tokio::main]
async fn main() {
    dotenvy::dotenv().ok();

    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .init();

    let database_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");

    let pool = PgPool::connect(&database_url)
        .await
        .expect("Failed to connect to database");

    sqlx::migrate!().run(&pool).await
        .expect("Failed to run migrations");

    let state = AppState { db: pool };

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

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    tracing::info!("Server running on port 3000");
    axum::serve(listener, app).await.unwrap();
}

Docker Deployment

One thing I wish I’d known earlier: Rust Docker builds are slow by default. A multi-stage build with cargo-chef caches your dependency compilation layer, cutting rebuild times from 10+ minutes to under 60 seconds on incremental builds.

# Stage 1: Dependency caching
FROM rust:1.78-slim as chef
RUN cargo install cargo-chef
WORKDIR /app

FROM chef as planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

# Stage 2: Build
FROM chef as builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release

# Stage 3: Minimal runtime image
FROM debian:bookworm-slim as runtime
RUN apt-get update && apt-get install -y \
    libssl3 ca-certificates \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/rust-api .
COPY migrations ./migrations
EXPOSE 3000
CMD ["./rust-api"]

The final image lands around 80-100MB — far smaller than the 400-700MB you’d see from a node:18 image that bundles the entire V8 runtime.

# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://postgres:password@db:5432/rustapi
      - RUST_LOG=info
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: rustapi
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
docker compose up --build

Testing the API

# Create a user
curl -X POST http://localhost:3000/users \
  -H 'Content-Type: application/json' \
  -d '{"username": "alice", "email": "[email protected]"}'

# List all users
curl http://localhost:3000/users

# Get by ID
curl http://localhost:3000/users/<uuid>

Lessons From Real-World Use

  • Use sqlx::query_as! macros over raw query strings. The compile-time check pays for itself immediately. Set DATABASE_URL in your .env before running cargo build.
  • Keep handlers thin. Business logic in handlers becomes painful to test. Extract it into service functions that take the pool as a parameter — those are easy to unit test without spinning up a server.
  • Handle database constraint errors explicitly. Matching on e.constraint() lets you return meaningful 409 Conflict responses instead of generic 500s when users try to register duplicate emails.
  • Log with structured context. tracing spans let you attach request IDs and user IDs to every log line within a request’s lifecycle — essential when debugging concurrent production requests.
  • Run migrations on startup in development, but separately in production. Calling sqlx::migrate!() in main() is convenient locally, but in production you want migration control decoupled from app restarts.
  • Replace CorsLayer::permissive() before shipping. It allows requests from any origin, any method, any header. Scope it down with CorsLayer::new().allow_origin([...]) before you go live.

Where to Go From Here

With this foundation running, the obvious extensions are JWT authentication (the jsonwebtoken crate integrates cleanly with Axum extractors as middleware), cursor-based pagination on list endpoints, and an OpenAPI spec via utoipa — which generates interactive Swagger UI docs directly from your handler types.

The compile-time guarantees that felt restrictive when I first learned Rust are now the main reason I reach for it on new services. The feedback loop shifts — you catch type mismatches and missing fields in development, not through a 3am alert. For a team maintaining the same API over two or three years, that’s a trade worth making early.

Share: