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.
Update history
- — Adopted from previous gaborl.hu (Hugo) site via legacy import.