Free Handbook · Every example compiled & verified

Rust + Tools

Nine labs wiring Rust into a real stack: Cargo and crates.io, an axum REST API, Postgres with sqlx, Redis, Kafka, AWS, Docker, Kubernetes and GitHub Actions.

0 / 140 lessons🔥 0 day streak
ShareXLinkedIn

Module 12 · what you'll be able to do

  • Manage a Cargo project the way teams do: cargo add, Cargo.lock, features, release profiles and crates.io
  • Build an async JSON REST API with tokio, axum and serde
  • Talk to Postgres with sqlx, cache in Redis, and publish events to Kafka
  • Call AWS services with the official AWS SDK for Rust
  • Ship a Rust service in a small multi-stage Docker image, run it on Kubernetes, and gate every pull request with fmt, clippy and tests in GitHub Actions
01

Cargo and crates.io: the daily workflow

Everything up to now used only the standard library, so every example could be compiled and checked. Real Rust projects lean on crates, the packages published on crates.io. The code in this module is static (it needs crates and running services), so treat each lab as a recipe to try in your own Cargo project. The standard library is deliberately small: async runtimes, HTTP, JSON, database drivers and random numbers all live in crates.

Rust + Git

Start a project the way teams do

cargo new creates Cargo.toml, src/main.rs, a .gitignore that excludes target/, and initialises a Git repository. cargo add edits Cargo.toml for you and picks the latest compatible version. The first build writes Cargo.lock, the exact version of every dependency. Commit it: for applications it guarantees that CI, your teammates and production build the same code.
bash
cargo new todo-api            # Cargo.toml, src/main.rs, .gitignore, git init
cd todo-api

cargo add tokio --features full
cargo add axum
cargo add serde --features derive
cargo add serde_json

cargo build                   # downloads crates, writes Cargo.lock
git add . && git commit -m "Scaffold todo-api"

cargo tree -d                 # dependencies pulled in at two versions
cargo update -p serde         # bump one crate within its semver range
cargo check                   # fast type-check, no binary
cargo build --release         # optimised binary in target/release/
tomlCargo.toml
[package]
name = "todo-api"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

[dev-dependencies]
# only compiled for tests and examples

[profile.release]
lto = true          # link-time optimisation: smaller, faster binary
codegen-units = 1
strip = true        # drop debug symbols from the release binary

"1" means "any 1.x at or above 1.0.0" (semver caret rule). Features switch on optional parts of a crate, so you only compile what you use.

Publishing a library
For a library, add description, license and repository to [package], run cargo publish --dry-run, then cargo publish with a token from crates.io. Published versions are permanent; cargo yank only stops new projects from picking a broken version. Workspaces ([workspace] members = [...]) keep several crates in one repository with a shared Cargo.lock.
02

An async REST API with tokio, axum and serde

Three crates form the backbone of most Rust web services. tokio is the async runtime: it runs thousands of tasks on a few OS threads, switching whenever one waits on the network. serde converts Rust structs to and from JSON (and YAML, TOML, CSV...) through #[derive(Serialize, Deserialize)]. axum, from the tokio team, maps routes to async fn handlers whose arguments ("extractors") pull data out of the request. actix-web is the other popular choice, with a similar feel.

rustsrc/json.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Order {
    order_id: u64,
    customer_email: String,
    #[serde(default)]
    items: Vec<String>,          // missing in the JSON -> empty Vec
    #[serde(skip_serializing_if = "Option::is_none")]
    coupon: Option<String>,      // omitted from output when None
}

fn demo() -> serde_json::Result<()> {
    let raw = r#"{"orderId": 7, "customerEmail": "ana@example.com"}"#;
    let order: Order = serde_json::from_str(raw)?;
    println!("{:?}", order);
    println!("{}", serde_json::to_string(&order)?);
    // {"orderId":7,"customerEmail":"[email protected]","items":[]}
    Ok(())
}

serde on its own: a typo in a field name or a wrong type becomes an Err from from_str, never a silently missing value.

Rust + Linux

A JSON todo API in one file

Run it with cargo run, then curl -X POST localhost:3000/todos -H "content-type: application/json" -d '{"title":"ship it"}' and curl localhost:3000/todos/1. Shared state is an Arc<Mutex<...>> exactly as in Module 10; a standard Mutex is fine because no guard is held across an .await. cargo build --release gives one self-contained binary you can copy to any Linux server of the same architecture and run under systemd, with no runtime to install.
rust
use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::get,
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};

#[derive(Clone, Serialize)]
struct Todo {
    id: u64,
    title: String,
    done: bool,
}

#[derive(Deserialize)]
struct NewTodo {
    title: String,
}

type Db = Arc<Mutex<Vec<Todo>>>;

async fn list(State(db): State<Db>) -> Json<Vec<Todo>> {
    Json(db.lock().unwrap().clone())
}

async fn create(State(db): State<Db>, Json(input): Json<NewTodo>) -> (StatusCode, Json<Todo>) {
    let mut todos = db.lock().unwrap();
    let todo = Todo { id: todos.len() as u64 + 1, title: input.title, done: false };
    todos.push(todo.clone());
    (StatusCode::CREATED, Json(todo))
}

async fn get_one(State(db): State<Db>, Path(id): Path<u64>) -> Result<Json<Todo>, StatusCode> {
    db.lock()
        .unwrap()
        .iter()
        .find(|t| t.id == id)
        .cloned()
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}

#[tokio::main]
async fn main() {
    let db: Db = Arc::new(Mutex::new(Vec::new()));
    let app = Router::new()
        .route("/todos", get(list).post(create))
        .route("/todos/{id}", get(get_one))
        .with_state(db);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
Arc<Mutex<T>> in Module 10 →
Async gotchas that bite in production
Never call blocking code (heavy CPU work, std::fs, std::thread::sleep) directly inside an async handler: it stalls every task on that worker thread. Use tokio::task::spawn_blocking or the async versions in tokio::fs and tokio::time. And if you must hold a lock across an .await, use tokio::sync::Mutex, not the standard one.
03

Postgres with sqlx, and Redis for caching

sqlx is an async SQL toolkit: you write plain SQL, bind parameters with $1, and map rows onto structs. Its query! and query_as! macros go further and check each query against a live database at compile time, so a misspelled column is a build error. Diesel (a query builder) and SeaORM (an ORM) are the main alternatives. If your SQL is rusty, SQL Mastery covers the queries themselves.

Rust + PostgreSQL

Connection pool, insert and typed select with sqlx

Add the crates with cargo add sqlx --features runtime-tokio,postgres and cargo add tokio --features full, start Postgres (docker run -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres), and set DATABASE_URL=postgres://postgres:dev@localhost/postgres. The pool is cheap to clone, so in an axum app you put it in the router state and every handler shares it. Parameters are always sent separately from the SQL text, which rules out SQL injection.
rust
use sqlx::postgres::PgPoolOptions;
use sqlx::FromRow;

#[derive(Debug, FromRow)]
struct User {
    id: i64,
    email: String,
}

#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
    let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let pool = PgPoolOptions::new().max_connections(5).connect(&url).await?;

    sqlx::query(
        "CREATE TABLE IF NOT EXISTS users (
            id BIGSERIAL PRIMARY KEY,
            email TEXT UNIQUE NOT NULL
        )",
    )
    .execute(&pool)
    .await?;

    let id: i64 = sqlx::query_scalar(
        "INSERT INTO users (email) VALUES ($1)
         ON CONFLICT (email) DO UPDATE SET email = EXCLUDED.email
         RETURNING id",
    )
    .bind("[email protected]")
    .fetch_one(&pool)
    .await?;

    let users: Vec<User> = sqlx::query_as("SELECT id, email FROM users WHERE id <= $1 ORDER BY id")
        .bind(id)
        .fetch_all(&pool)
        .await?;
    println!("{:?}", users);
    Ok(())
}
Rust + Redis

Sessions and counters in Redis

Add cargo add redis --features tokio-comp. A multiplexed connection can be cloned and shared by many tasks. The AsyncCommands trait gives typed methods (get, set_ex, incr); you choose the Rust type to read back, and a mismatch is a RedisError, not a panic. A common pattern is cache-aside: look in Redis, fall back to Postgres on a miss, then set_ex the result with a time-to-live.
rust
use redis::AsyncCommands;

#[tokio::main]
async fn main() -> redis::RedisResult<()> {
    let client = redis::Client::open("redis://127.0.0.1/")?;
    let mut con = client.get_multiplexed_async_connection().await?;

    // store a session for one hour
    let _: () = con.set_ex("session:42", "ana", 3600).await?;

    let user: Option<String> = con.get("session:42").await?;
    let missing: Option<String> = con.get("session:999").await?;
    let hits: i64 = con.incr("page:home:hits", 1).await?;

    println!("{:?} {:?} {}", user, missing, hits);
    Ok(())
}
04

Events with Kafka, and AWS with the official SDK

Rust shows up in data platforms where throughput and predictable latency matter: stream processors, log shippers, ingestion services. For Kafka the standard crate is rdkafka, a wrapper around the battle-tested C library librdkafka (it needs a C toolchain and CMake at build time). If you are building pipelines, the Data Engineering course covers Kafka itself.

Rust + Apache Kafka

Publish order events with rdkafka

Add cargo add rdkafka --features cmake-build and cargo add tokio --features full. Messages with the same key always go to the same partition, so every event for order-1 stays in order. send returns a future that completes when the broker acknowledges the write, or with the error and the original message so you can retry it.
rust
use rdkafka::config::ClientConfig;
use rdkafka::producer::{FutureProducer, FutureRecord};
use std::time::Duration;

#[tokio::main]
async fn main() {
    let producer: FutureProducer = ClientConfig::new()
        .set("bootstrap.servers", "localhost:9092")
        .set("message.timeout.ms", "5000")
        .create()
        .expect("producer creation failed");

    for i in 1..=3 {
        let key = format!("order-{}", i);
        let payload = format!(r#"{{"order_id":{},"status":"created"}}"#, i);
        let result = producer
            .send(FutureRecord::to("orders").key(&key).payload(&payload), Duration::from_secs(0))
            .await;
        match result {
            Ok(delivery) => println!("delivered {}: {:?}", key, delivery),
            Err((err, _msg)) => eprintln!("failed {}: {}", key, err),
        }
    }
}
Rust + AWS

List S3 objects with the AWS SDK for Rust

Add cargo add aws-config, cargo add aws-sdk-s3 and tokio. load_defaults finds credentials the same way the AWS CLI does (environment variables, ~/.aws, or the IAM role of the EC2 instance, ECS task or Lambda function). Every service is its own crate (aws-sdk-dynamodb, aws-sdk-sqs...), so you only compile what you use. For AWS Lambda, the cargo lambda tool builds and deploys Rust functions, which start in milliseconds.
rust
use aws_config::BehaviorVersion;

#[tokio::main]
async fn main() -> Result<(), aws_sdk_s3::Error> {
    let config = aws_config::load_defaults(BehaviorVersion::latest()).await;
    let s3 = aws_sdk_s3::Client::new(&config);

    let resp = s3
        .list_objects_v2()
        .bucket("my-reports")
        .prefix("daily/")
        .send()
        .await?;

    for obj in resp.contents() {
        println!("{} ({} bytes)", obj.key().unwrap_or("?"), obj.size().unwrap_or(0));
    }
    Ok(())
}
05

Ship it: Docker and Kubernetes

A Rust service compiles to one native binary, which makes its container image tiny: build with the full Rust toolchain in one stage, then copy only the binary into a slim runtime image. The final image is typically tens of megabytes instead of the roughly 1 GB build image.

Rust + Docker

A multi-stage Dockerfile with cached dependencies

The trick in the build stage: copy only Cargo.toml and Cargo.lock first and build a dummy main. Docker caches that layer, so changing your own code does not recompile every dependency. Build with docker build -t todo-api . and run with docker run -p 3000:3000 todo-api. Keep the build and runtime images on the same Debian release so the binary finds the glibc it was linked against; add ca-certificates if the service makes HTTPS calls.
dockerfile
# ---- build stage ----
FROM rust:1-slim-trixie AS build
WORKDIR /app

# 1. dependencies only: cached until Cargo.toml or Cargo.lock change
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs \
    && cargo build --release \
    && rm -rf src

# 2. your code
COPY src ./src
RUN touch src/main.rs && cargo build --release

# ---- runtime stage ----
FROM debian:trixie-slim
RUN useradd --system --no-create-home app
COPY --from=build /app/target/release/todo-api /usr/local/bin/todo-api
USER app
EXPOSE 3000
CMD ["todo-api"]
Rust + Kubernetes

Deploy with probes and small resource requests

Rust services start fast and use little memory, so requests can be small and scaling out is quick. Add a cheap /health route to the axum router (.route("/health", get(|| async { "ok" }))) for the probes. Handle SIGTERM with axum's with_graceful_shutdown so in-flight requests finish during a rolling update.
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: todo-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: todo-api
  template:
    metadata:
      labels:
        app: todo-api
    spec:
      containers:
        - name: todo-api
          image: ghcr.io/example/todo-api:1.0.0
          ports:
            - containerPort: 3000
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: todo-api-db
                  key: url
          resources:
            requests:
              cpu: 50m
              memory: 32Mi
            limits:
              memory: 128Mi
          readinessProbe:
            httpGet:
              path: /health
              port: 3000
          livenessProbe:
            httpGet:
              path: /health
              port: 3000
            periodSeconds: 20
06

fmt, clippy and tests in GitHub Actions

The standard Rust CI gate is three commands: cargo fmt --check (formatting), cargo clippy -- -D warnings (lints, with every warning treated as an error) and cargo test. Run the same three locally before you push; Module 11 shows what clippy catches.

Rust + GitHub

A CI workflow for every pull request

Save as .github/workflows/ci.yml. dtolnay/rust-toolchain installs stable Rust with the two components, and Swatinem/rust-cache caches ~/.cargo and target/ between runs, which usually cuts CI time by more than half. Mark the job as a required check in the branch protection settings so nothing merges red.
yaml
name: ci

on:
  push:
    branches: [main]
  pull_request:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy, rustfmt
      - uses: Swatinem/rust-cache@v2
      - run: cargo fmt --all --check
      - run: cargo clippy --all-targets --all-features -- -D warnings
      - run: cargo test --all-features
Two more checks worth adding
cargo audit (from the RustSec advisory database) fails the build when a dependency has a known vulnerability, and cargo deny also checks licenses and duplicate versions. Both are one extra run: line once installed.
07

Choosing crates, and where to go next

Before adding a crate, check its download count, the date of its last release, and whether its maintainers answer issues. Every dependency is code you ship.
JobGo-to cratesNotes
Async runtimetokioThe default; most networking crates assume it
Web frameworkaxum, actix-webaxum builds on tokio and tower middleware
HTTP clientreqwestAsync and blocking APIs, JSON via serde
JSON / configserde, serde_json, tomlDerive once, use for every format
Databasessqlx, diesel, sea-ormRaw SQL with compile-time checks, query builder, ORM
Errorsthiserror (libraries), anyhow (apps)Automate the patterns from Module 06
Logging and tracingtracing, tracing-subscriberStructured logs and spans; exports to OpenTelemetry
CLI toolsclapArgument parsing from a derived struct
Data parallelismrayonChange iter() to par_iter() to use every core

Rust also compiles to WebAssembly: wasm-pack and wasm-bindgen turn a Rust library into a module that JavaScript can import, which is how several browser tools run heavy image, video and parsing work at near-native speed. If you work in the browser too, the TypeScript handbook is the other half of that pairing.

Quick check

Why does the Dockerfile copy Cargo.toml and Cargo.lock and build a dummy main before copying src/?

Crate
A Rust package: a library or binary with its own Cargo.toml, usually published on crates.io.
Cargo.lock
The exact resolved version of every dependency. Commit it for applications so every build is reproducible.
Feature
An optional part of a crate turned on in Cargo.toml, such as serde's derive.
tokio
The most used async runtime: schedules many async tasks onto a small pool of OS threads.
Extractor
An axum handler argument (Json, Path, State) that pulls typed data out of the request.
Connection pool
A shared set of open database connections that handlers borrow and return, instead of connecting per request.
Multi-stage build
A Dockerfile with a heavy build image and a slim runtime image; only the final binary is copied across.
Mid-levelYour axum service gets slow under load, and profiling shows the tokio worker threads blocked. What do you look for?

Blocking work inside async handlers: synchronous file or network I/O, std::thread::sleep, heavy CPU work such as hashing passwords or parsing large files, or a standard Mutex held for a long time. Each one parks a worker thread, so every other task scheduled on it waits. The fixes are the async versions (tokio::fs, tokio::time::sleep), moving CPU-bound work to spawn_blocking or a rayon pool, and keeping lock scopes short (or switching to tokio::sync::Mutex when a guard must cross an .await).

What they are really testing: Whether you understand that async is cooperative, so one blocking call starves the other tasks sharing the thread.

Frequently asked questions

Which Rust web framework should I learn first?
axum. It is built by the tokio team, uses plain async functions as handlers, shares middleware with the wider tower ecosystem, and is the most common choice in new projects. actix-web is equally production-ready and very fast; the concepts transfer directly.
Should I commit Cargo.lock?
Yes for applications and services, so every build uses the exact same dependency versions. Current Cargo guidance also recommends committing it for libraries, since it makes CI reproducible; users of your library still resolve their own versions.
How small can a Rust Docker image be?
With a multi-stage build and a slim Debian runtime, typically 30 to 90 MB. A statically linked musl binary on a distroless or scratch image can be under 20 MB.

Finish the Rust handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.