For small IoT backends, Axum provides enough structure without heavy framework overhead. My baseline service exposes ingestion, latest status, and historical query endpoints.

I validate payloads at the edge and normalize units before writing to storage. This keeps downstream analytics consistent.

Authentication is token-based with per-device scopes. Devices can submit only to their assigned namespace, while dashboards get read-only tokens.

The service is packaged with health endpoints, structured logs, and graceful shutdown hooks so it can run cleanly under systemd or containers.

A minimal router

The whole baseline fits in one file. A /health GET for probes, a /telemetry POST for ingestion, and shared state passed through an extractor:

use axum::{
    extract::State,
    http::StatusCode,
    routing::{get, post},
    Json, Router,
};
use serde::Deserialize;
use std::sync::Arc;

#[derive(Clone)]
struct AppState {
    store: Arc<Store>, // Arc so every handler clone shares one backend
}

#[derive(Deserialize)]
struct Telemetry {
    device_id: String,
    metric: String,
    value: f64,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let state = AppState { store: Arc::new(Store::new()) };

    let app = Router::new()
        .route("/health", get(|| async { StatusCode::OK }))
        .route("/telemetry", post(ingest))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

async fn ingest(
    State(state): State<AppState>,
    Json(payload): Json<Telemetry>,
) -> StatusCode {
    // Validate/normalize at the edge before persisting.
    if payload.value.is_nan() {
        return StatusCode::UNPROCESSABLE_ENTITY;
    }
    state.store.insert(&payload.device_id, &payload.metric, payload.value);
    StatusCode::ACCEPTED
}

State and extractors

Two things carry most of the weight here. State<AppState> is Axum's typed way to inject shared state into handlers; because AppState is Clone and holds an Arc, cloning is cheap and every request sees the same store. Json<Telemetry> is an extractor that deserializes and validates the body before my handler runs — a malformed payload becomes a 400 automatically, so ingest only ever sees a well-formed Telemetry. Extractors are also where I hang auth: a custom extractor that pulls the per-device token and rejects with 401 keeps that concern out of every handler body.

Sources

  • Imported from the previous version of the site (gaborl.hu). Original publication around 2024-09-30.