Rust is a good fit for backend services that must stay small, predictable, and fast. Its compiler catches a large class of errors before code runs, and a release binary has few runtime dependencies. Those strengths come with a cost: the edit-compile-run loop is slower than in most interpreted stacks, and a service that runs alongside others needs the same plumbing as any other service. This article walks through a practical Rust HTTP service with axum, from the project layout to the container image, and then looks at what changes once it lives in a multi-service system.
Choosing the framework
Rust has several web frameworks. axum is built on tokio and the tower ecosystem, and it is the one most teams reach for when they want to compose middleware and keep routing explicit. Its handlers are plain async functions, and its extractors make request parsing type-checked.
For a new service, pin the major version of the framework and the async runtime in Cargo.toml. A service that builds today with an unpinned dependency may fail next month for reasons unrelated to its own code.
A minimal service
A service needs three things before it is useful: a route that reports health, a way to read its port from the environment, and a bind address that accepts connections from outside its own process.
use axum::{routing::get, Json, Router};
use serde_json::{json, Value};
fn app() -> Router {
Router::new().route("/health", get(health))
}
async fn health() -> Json<Value> {
Json(json!({ "status": "ok" }))
}
#[tokio::main]
async fn main() {
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}"))
.await
.expect("failed to bind the port");
axum::serve(listener, app()).await.expect("server error");
}
Three details matter more than they look:
-
Bind to
0.0.0.0, not127.0.0.1. Inside a container, a service bound to loopback accepts no traffic from the proxy or from other containers. -
Read the port from
PORT. Containers and orchestrators assign ports at runtime. A hard-coded port forces every environment to match the code. - Keep the health route trivial. It should prove the process is serving requests, not run a database query. A health check that depends on the database turns a brief database blip into a full service restart.
Separate the router from main, as in the app() function above. The router can then be tested without binding a port.
Testing the handler
A unit test for the health route calls the handler directly and checks the response body. An integration test builds the router, sends a request through it with a test client, and checks the status code. Both run quickly and do not need a network.
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn health_reports_ok() {
let Json(body) = health().await;
assert_eq!(body["status"], "ok");
}
}
Run the tests with cargo test before building the image. A failing test is a much cheaper signal than a failing container.
Building the image
A Rust image should use two stages: a build stage with the full toolchain, and a small runtime stage that carries only the binary and its system libraries.
The build stage compiles the release binary. The runtime stage copies that binary and nothing else. The result is a small image with a small attack surface and no compiler in production.
Two build details make rebuilds practical:
-
Cache the dependency layer. Copy
Cargo.tomlandCargo.lockfirst, build a dummy binary to fetch and compile dependencies, and only then copy the source. A source edit then recompiles only your crate, not the whole dependency tree. -
Use BuildKit cache mounts for the cargo registry and the
targetdirectory, if your builder supports them. They keep compiled artifacts between builds, which matters most on a laptop.
Keep the runtime stage on a slim base with the minimum system libraries, and run the process as a non-root user where the base image allows it.
Running more than one service
A single Rust service is easy. The difficulty appears when it must call other services, read a database, and start after them.
Three rules keep a multi-service Rust system manageable:
- Declare dependencies explicitly. A service that needs a database should not start until the database accepts connections. Health-gated startup order is the usual answer.
- Use timeouts on every outbound call. A Rust client with no timeout waits as long as the remote service takes, and it will hold a task and a connection the whole time.
- Propagate a correlation ID. Read it from the incoming request, add it to your logs, and pass it on to downstream calls. Tracing one request across services depends on it.
Local development trade-offs
Rust's compile times are the main cost in a local loop. A change to one module may rebuild a large crate, and an image rebuild adds the container's own overhead. Teams often respond in one of two ways: run the service directly with cargo run while developing it, and reserve the container for integration checks, or accept image rebuilds and keep the dependency cache warm.
Neither approach is free. The first means your local run and your container run can differ. The second means you wait for the compiler more often. Pick the trade-off that matches how often you change the service and how much you rely on its container behaviour.
Where TDK CLI fits
A Rust service becomes one resource in a larger local system. TDK CLI can scaffold that resource with tdk resource <name> --type backend --language rust. The scaffold uses axum 0.8 and tokio, serves GET /health, reads PORT, binds 0.0.0.0, and includes a unit test. The engine generates the image with a rust:1-slim-bookworm build stage, a cargo test stage, and a small debian:bookworm-slim production stage with a health check. Like the other compiled languages it has no live update, so Tilt rebuilds the image when a watched file changes. tdk up then starts the service with the rest of the stack, and tdk status --json reports whether it is ready.
The provider's docs describe Rust as an opt-in language. Omitting --language still produces the default Bun and Hono scaffold, so existing projects are unaffected.
Top comments (0)