The first time you open a new web framework, you should not have to relearn what a server is.
If you already know Ktor, you know the feeling I wanted from Churust: create an application, install the capabilities you need, describe your routes in one readable block, and get on with building the product. If you do not know Ktor, the same API is still small enough to learn in an afternoon.
That is Churust: an open-source Rust web framework designed to be simple, secure, robust, and easy to learn.
It gives you Ktor’s developer experience on Rust’s async stack. The application engine runs on tokio, HTTP is handled by hyper, and optional TLS is provided by rustls. Churust does not try to replace those proven foundations. It gives them a cohesive, friendly API.
And yes, the name is exactly what it sounds like: churro plus Rust.
Key Takeaways
- Churust is deliberately shaped like Ktor: a server builder, a nested routing DSL,
install(plugin), and a fixed request pipeline.- A working server needs one dependency.
#[churust::main]uses Churust’s re-exported tokio runtime.- Typed extractors make handlers short and explicit, while the full
Callremains available whenever you want lower-level control.- Security is part of the default configuration: headers, body and connection limits, timeouts, strict path handling, safe cookie defaults, and panic isolation are already on.
- JSON, logging, CORS, authentication, compression, rate limiting, templates, sessions, Redis, WebSockets, static files, multipart, an HTTP client, OpenAPI, TLS, HTTP/2, and HTTP/3 are all part of the first-party feature set.
- Churust 0.3 is backed by a large regression suite, strict CI, weekly dependency checks, public design documents, a detailed changelog, and a private vulnerability-reporting process.
- The full user guide covers quick start, fundamentals, every plugin, and recipes — separate from the API docs on docs.rs.
Why I built Churust
I write a lot of Kotlin, and I write Rust where its safety and performance genuinely pay off. That combination has taken me from Rust-powered mobile libraries to servers that need to speak to them.
Every time I started one of those services, I found myself missing Ktor.
Not Kotlin itself. The part I missed was Ktor’s shape:
- Configuration reads like configuration.
- Routes live together and are easy to scan.
- Cross-cutting behaviour is installed as a plugin.
- Plugins run in predictable phases.
- Common server capabilities feel like one framework instead of a shopping list.
Rust already has an excellent networking ecosystem. What I wanted was a framework that made that ecosystem feel immediately familiar to a Kotlin developer, without hiding Rust’s type system or pretending async work is simpler than it is.
So I built Churust.
The goal is not to make Rust look like Kotlin. The goal is to remove the unnecessary learning curve around the server itself. You still get Rust’s ownership model, enums, traits, explicit errors, and compile-time guarantees. You just do not need to invent your application’s basic shape before writing its first route.
Your first Churust server
Start with one dependency:
[dependencies]
churust = "0.3"
Then write the server:
use churust::prelude::*;
#[churust::main]
async fn main() -> std::io::Result<()> {
Churust::server()
.port(8080)
.routing(|r| {
r.get("/", |_call: Call| async {
"Hello from Churust 🌀"
});
r.get("/users/{id}", |Path(id): Path<u64>| async move {
format!("user #{id}")
});
})
.start()
.await
}
Run it and call both routes:
cargo run
curl http://localhost:8080/
curl http://localhost:8080/users/42
There is no separate tokio dependency in that project. Churust re-exports the runtime it uses, and #[churust::main] expands against that re-export. You can add tokio yourself if you need extra runtime features, but you do not need to solve a version-matching problem just to print “Hello.”
That small detail captures the philosophy of the framework: make the first step obvious, then let the application grow without changing its mental model.
If Ktor feels familiar, Churust will too
Here is a small Ktor route:
fun Application.module() {
routing {
get("/users/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest)
call.respondText("user #$id")
}
}
}
And here is the Churust version:
Churust::server()
.routing(|r| {
r.get("/users/{id}", |Path(id): Path<u64>| async move {
format!("user #{id}")
});
})
The syntax is Rust, but the structure is already in the right place in your head. The route is inside routing, the path parameter belongs to the handler, and invalid input is rejected before the handler body runs.
Plugins follow the same idea:
Churust::server()
.install(CallLogging::new())
.install(ContentNegotiation::new())
.install(Cors::new().allow_origin("https://app.example.com"))
.install(Compression::new())
.install(RateLimit::per_minute(120))
.routing(|r| {
// Your routes
})
If you have used install(ContentNegotiation) or install(Authentication) in Ktor, there is very little to translate. If you have not, the builder still reads from top to bottom: configure the server, install behaviour, declare routes, start.
A real JSON API
Hello World examples are useful, but a framework earns its place when the application needs state, JSON, authentication, logging, CORS, compression, and rate limiting at the same time.
Enable the complete plugin set:
[dependencies]
churust = { version = "0.3", features = ["full"] }
serde = { version = "1", features = ["derive"] }
Then build the API:
use churust::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
#[derive(Clone, Serialize)]
struct Note {
id: u64,
text: String,
}
#[derive(Deserialize)]
struct NewNote {
text: String,
}
#[derive(Clone)]
struct AdminUser {
name: String,
}
struct Store {
notes: Mutex<Vec<Note>>,
}
#[churust::main]
async fn main() -> std::io::Result<()> {
let admin_token =
std::env::var("ADMIN_TOKEN").expect("ADMIN_TOKEN must be set");
Churust::server()
.state(Store {
notes: Mutex::new(Vec::new()),
})
.install(CallLogging::new())
.install(ContentNegotiation::new())
.install(Cors::new().allow_origin("https://app.example.com"))
.install(Compression::new())
.install(RateLimit::per_minute(120).burst(20))
.install(Auth::bearer(move |token: String| {
let expected = admin_token.clone();
async move {
churust::secure_compare(
token.as_bytes(),
expected.as_bytes(),
)
.then(|| AdminUser {
name: "admin".into(),
})
}
}))
.routing(|r| {
r.get("/notes", |store: State<Store>| async move {
Json(store.notes.lock().unwrap().clone())
});
r.post(
"/notes",
|Principal(admin): Principal<AdminUser>,
store: State<Store>,
Json(input): Json<NewNote>| async move {
let mut notes = store.notes.lock().unwrap();
let note = Note {
id: notes.len() as u64 + 1,
text: input.text,
};
notes.push(note.clone());
println!("{} created note #{}", admin.name, note.id);
(StatusCode::CREATED, Json(note))
},
);
})
.start()
.await
}
There is a lot happening here, but very little framework ceremony:
.state(Store)registers typed application state.State<Store>retrieves it in a handler.Json<NewNote>validates and deserializes the request body.- Returning
Json(note)serializes the response. Principal<AdminUser>makes the route private. Without a valid principal, the handler never runs.secure_compareavoids a timing-sensitive comparison for a request-supplied secret.- Logging, CORS, compression, and rate limiting stay outside the business logic.
That last point matters. Security checks and operational behaviour should be difficult to forget and easy to find.
The complete feature set
Churust keeps its core lean through Cargo features, but the important capabilities are first-party and versioned together. Default features are empty, so you only compile what the service uses.
| Feature | What it gives you |
|---|---|
| Core | Server engine, routing, typed extractors, call-style handlers, responses, errors, cookies, signed sessions, identity, typed state, layered configuration, security defaults, HTTP/1.1, HTTP/2, streaming bodies, graceful shutdown, and TestClient. |
json |
Json<T> requests and responses plus the ContentNegotiation plugin. |
logging |
CallLogging, request IDs, x-request-id, and W3C traceparent continuation. |
cors |
Restrictive CORS configuration, preflight handling, and response headers. |
auth |
Bearer, Basic, and JWT authentication plus Principal<P> authorization. |
ratelimit |
GCRA rate limiting with exact Retry-After, peer-based keys, custom keys, and route-scoped use. |
compression |
Brotli, gzip, and deflate response compression, including streamed responses. |
templates |
minijinja templates parsed at startup and HTML-escaped regardless of filename. |
full |
The seven installable plugins above: JSON, logging, CORS, auth, rate limiting, compression, and templates. |
redis |
Revocable server-side sessions through RedisStore. |
client |
A pooled outbound HTTP client with request timeouts, bounded response bodies, and controlled redirects. |
client-tls |
HTTPS support for the outbound client. |
openapi |
OpenAPI 3.1 generated from the router, with explicit operation descriptions and drift checks. |
ws |
WebSocket upgrades, messages, and bidirectional sockets. |
fs |
Secure static-file serving, conditional requests, byte ranges, and opt-in directory listings. |
multipart |
Buffered Multipart and incremental MultipartStream uploads. |
tls |
HTTPS through rustls. |
http3 |
HTTP/3 over QUIC on a separate listener, including Alt-Svc advertisement from the TCP server. |
tower in churust-core |
An adapter for using a tower::Service as Churust middleware. |
The core also handles the small pieces that otherwise spread across every service. HEAD and OPTIONS are automatic, and 405 Method Not Allowed uses the same sorted Allow value as OPTIONS. on_error gives the application one place to render its own 4xx and 5xx pages, including router failures. Custom application errors can implement IntoError, which lets ? work in a handler without exposing the error’s Display text to the client. The peer address is available for audit logs and trusted-proxy-aware policies.
All fourteen Churust crates release in lockstep with the same version. In normal application code, you depend on the umbrella churust crate and select features there. That keeps the framework, plugins, client, and integrations aligned in one lockfile.
For a feature-rich HTTPS service, the dependency can still be one line:
churust = {
version = "0.3",
features = [
"full",
"redis",
"client-tls",
"openapi",
"ws",
"fs",
"multipart",
"tls",
"http3",
],
}
Typed handlers without losing control
Churust supports two handler styles, and they work together.
The high-level style uses extractors:
r.get(
"/teams/{team_id}/members/{member_id}",
|Path((team_id, member_id)): Path<(u64, u64)>,
Query(filters): Query<MemberFilters>,
store: State<Store>| async move {
// team_id, member_id, filters, and store are already typed
},
);
Request-head extractors such as Path<T>, Query<T>, headers, state, bearer tokens, and principals can appear in any position. A body-consuming extractor such as Json<T>, Form<T>, String, Payload, Multipart, or the full Call must come last.
That rule exists because a request body is a one-shot stream, and Churust enforces it at compile time. Version 0.3 also adds custom compiler diagnostics so an invalid handler signature explains the rule instead of leaving you with an opaque trait error.
Optional input is explicit:
r.get("/search", |query: Option<Query<Filters>>| async move {
match query {
Some(Query(filters)) => format!("filtering by {}", filters.tag),
None => "showing everything".to_string(),
}
});
Absent input becomes None. Malformed input remains an error. A broken query does not quietly turn into an unfiltered request.
And when extractors are not the right tool, take the complete call:
r.post("/uppercase", |mut call: Call| async move {
let body = call.receive_text().await?;
Ok::<_, Error>(Response::text(body.to_uppercase()))
});
The convenient path is short, and the escape hatch is always there.
Plugins run in a predictable pipeline
Churust uses five named phases:
Setup → Monitoring → Plugins → Call → Fallback
A plugin registers its middleware in the phase where it belongs. Logging stays in Monitoring; CORS, authentication, JSON, compression, and similar behaviour live in Plugins.
That means execution order is a property of the framework, not an accident of which .install(...) line appears first. The model is deterministic, easy to explain, and familiar to Ktor developers.
Middleware can also be scoped to part of the route tree:
.routing(|r| {
r.get("/health", |_call: Call| async { "ok" });
r.route("/api", |api| {
api.intercept(RateLimit::per_minute(60));
api.get("/profile", |user: Principal<User>| async move {
Json(user.0)
});
});
})
Route guards can distinguish routes by host, header, or a custom predicate, and per-route body limits let an upload endpoint accept more than the rest of the application without raising the global ceiling.
Secure by default means real defaults
I do not want “secure by default” to be a slogan. I want it to describe what the server does when you forget to configure something.
This is the default posture in Churust 0.3:
| Default | Value | Why it matters |
|---|---|---|
| Security headers | X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer |
Reduces MIME sniffing, clickjacking, and referrer leakage. |
| Request body limit | 1 MiB | Prevents an unbounded body from becoming an easy memory-exhaustion path. |
| Request timeout | 30 seconds | Stops a request from holding resources forever. |
| Header-read timeout | 10 seconds | Bounds slow-loris clients, including peers that connect and send nothing. |
| Header count | 100 | Bounds hostile or accidental header growth. |
| Path depth | 64 segments | Prevents pathological router work. |
| Concurrent connections | 25,000 | Puts a deliberate ceiling on tasks and file descriptors. |
| TLS handshakes | 256 with a 10-second deadline | Separately limits the more expensive handshake path. |
| Idle keep-alive | 75 seconds | Closes connections that go quiet. |
| WebSocket limits | 1 MiB per frame, 4 MiB per message | Bounds fragmented-message reassembly. |
| Path handling | PathPolicy::Strict |
Refuses repeated-slash aliases such as //admin. |
| Cookies | HttpOnly, SameSite=Lax, Path=/ |
Safer behaviour without repeating the same flags everywhere. |
| Handler panic | Isolated as a 500 response |
One bad request does not crash the server. |
| Version banner | Not sent | Avoids unnecessary implementation disclosure. |
Those protections also apply to transport-generated errors such as 400, 408, and 413, not only responses that reached a route.
Churust also refuses ambiguous request framing. A request containing both Transfer-Encoding and Content-Length is rejected and its connection is closed instead of guessing which length is correct. Duplicate routes and missing static roots fail during startup. Static-file paths are checked against traversal and symlink escapes. Directory listings are off unless you enable them, and filenames are escaped when you do.
TLS is opt-in because a framework should never pretend plaintext is encrypted. Once you enable the tls feature and provide a certificate and key, rustls handles the connection and ALPN negotiates HTTP/2 where the client supports it.
Authentication and sessions that are hard to forget
Authentication inserts your principal into the request. Authorization happens when a handler asks for it:
r.get(
"/account",
|Principal(user): Principal<User>| async move {
Json(user)
},
);
If authentication did not produce a User, that handler does not run. The requirement is visible in the function signature instead of hidden inside the body.
For session-backed applications, signed cookie sessions are available in core:
let session_key =
std::env::var("SESSION_KEY").expect("SESSION_KEY must be set");
Churust::server()
.install(
Sessions::cookie(session_key)
.secure(true)
.max_age(60 * 60 * 24),
)
.install(Identities::new())
Cookie sessions use HMAC-SHA256 signatures and constant-time verification. They are signed, not encrypted, so they are a good fit for small, non-secret session state.
When you need server-side state and immediate revocation, switch the store to Redis:
let store = RedisStore::connect("redis://127.0.0.1/").await?;
let app = Churust::server()
.install(Sessions::with_store(store.ttl(3600)))
.install(Identities::new())
.build();
The browser keeps an opaque identifier while the session lives in Redis. Logout deletes the server-side record, and a failed revocation is reported as an error instead of returning a successful logout while the session remains valid.
The identity layer includes login, logout, session rotation, an absolute login deadline, and an idle-visit deadline.
Streaming, files, WebSockets, and modern HTTP
Churust supports buffered bodies when they are convenient and streaming bodies when size matters.
Payload reads a request incrementally. MultipartStream does the same for file uploads, so memory usage does not grow with the entire upload. Streaming does not bypass the body limit; raise the limit deliberately on the upload route.
Response bodies can also stay streamed, including when compression is enabled. Static files support:
ETagandLast-Modified- Conditional requests and
304 Not Modified - Byte ranges with
206 Partial Contentand correct416responses - Optional index files
- Opt-in, escaped directory listings
- Protection against
.., absolute paths, encoded separators, and symlink escapes
WebSockets use a typed upgrade extractor:
use churust::prelude::*;
use churust::ws::{Message, WebSocketUpgrade};
r.get("/echo", |ws: WebSocketUpgrade| async move {
ws.on_upgrade(|mut socket| async move {
while let Some(Ok(message)) = socket.recv().await {
if matches!(message, Message::Close)
|| socket.send(message).await.is_err()
{
break;
}
}
})
});
The normal TCP engine supports HTTP/1.1 and HTTP/2. HTTP/3 is available behind http3 and runs over QUIC on its own UDP listener. The same App can serve both transports, and advertise_http3 adds the Alt-Svc header that tells compatible clients where to find it.
Configuration that grows with the service
Small applications can configure everything in Rust. Deployed services can layer a file and environment variables underneath the same builder.
# churust.toml
[server]
host = "0.0.0.0"
port = 8080
max_body_bytes = 1048576
request_timeout_ms = 30000
keep_alive_ms = 75000
max_connections = 25000
max_tls_handshakes = 256
tls_handshake_timeout_ms = 10000
shutdown_timeout_ms = 30000
path_policy = "strict"
[tls]
cert = "cert.pem"
key = "key.pem"
Load it with Churust::from_config(). Environment variables such as CHURUST_SERVER_PORT=9090 override the file, and values set in the Rust builder override both.
The order is:
secure defaults < churust.toml < CHURUST_* environment < Rust builder
That works well locally, in containers, and in environments where deployment configuration should stay outside the binary.
Churust can bind several addresses, use Unix domain sockets, limit concurrent connections and TLS handshakes, tune HTTP/2 streams and headers, and perform a bounded graceful shutdown that drains in-flight work.
The first-party client and OpenAPI support
A server usually calls another server. With the client-tls feature, the Churust client uses pooled connections and supports HTTP and HTTPS:
use churust::client::Client;
let client = Client::new()
.max_response_bytes(2 * 1024 * 1024)
.max_redirects(5);
let response = client
.get("https://api.example.com/health")
.send()
.await?;
println!("{}", response.text()?);
The request timeout covers the entire redirect chain, response bodies are bounded, credentials are stripped on cross-origin redirects, and an HTTPS request cannot be silently redirected down to HTTP.
OpenAPI support starts from the router because the router already knows which paths and methods exist:
use churust::openapi::{OpenApi, Operation};
let builder = Churust::server().routing(|r| {
r.get("/users/{id}", |_call: Call| async { "a user" });
});
let api = OpenApi::new("Users API", "1.0.0")
.from_routes(builder.routes())
.operation(
Method::GET,
"/users/{id}",
Operation::new()
.summary("Fetch one user")
.response(200, "The user")
.response(404, "No such user"),
);
let app = builder
.routing(|r| api.mount(r, "/openapi.json"))
.build();
Paths and parameters come from the real router. Descriptions and schemas remain explicit, and drift checks can fail a test when the routes and the document stop agreeing.
Testing is part of the developer experience
You do not need to bind a port to test an application. TestClient runs requests through the complete pipeline in process:
let app = Churust::server()
.routing(|r| {
r.get("/ping", |_call: Call| async { "pong" });
})
.build();
let client = TestClient::new(app);
let response = client.get("/ping").send().await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text(), "pong");
That makes route, extractor, plugin, error-page, and security behaviour fast to test without port conflicts or startup sleeps.
Version 0.3 grew the all-features workspace from 219 listed tests in 0.2 to 754 in 0.3. The important part is not the number by itself. Those tests cover the failures that only appear under load or at protocol boundaries: graceful shutdown, idle keep-alive, path normalization, request framing, conditional file requests, compression metadata, Redis revocation, redirects, and more.
What well maintained means for Churust
Maintenance is not a badge. It is the process around the code.
Every push and pull request runs:
- Formatting checks
- Clippy with warnings treated as errors
- Default-feature and all-feature matrices
- The full workspace tests
- Plugin-specific tests
- Example builds
- Documentation builds with warnings denied
- A check that every crate is present in the release list
CI pins Rust 1.96, the documented minimum supported Rust version. A separate workflow runs every Monday against the newest semver-compatible dependency versions, so upstream changes are discovered deliberately instead of waiting for the next manual lockfile update.
The changelog explains behaviour changes and the reason behind them. Design documents record the trade-offs. All fourteen crates are released together. Security reports have a private disclosure process with published response targets.
Churust is pre-1.0, so minor releases may include API changes while the public surface settles. That is not an excuse for surprise: changes are documented, tested, and released across the whole workspace together.
Getting started
The step-by-step guide lives at davthecoder.github.io/Churust — quick start, extractors, plugins, recipes, and reference. Use docs.rs/churust when you need the full type API.
For the smallest server:
[dependencies]
churust = "0.3"
For the complete plugin set plus WebSockets, static files, and TLS:
[dependencies]
churust = {
version = "0.3",
features = ["full", "ws", "fs", "tls"],
}
The repository includes four runnable examples:
git clone https://github.com/davthecoder/Churust.git
cd Churust
cargo run -p hello
cargo run -p api
cargo run -p chat
cargo run -p static-example
Start with hello, move to api to see plugins and typed authentication, use chat for WebSockets, and open static-example for file serving and streamed responses.
The main links are:
- User guide (GitHub Pages)
- Churust on GitHub
- Churust on crates.io
- API documentation on docs.rs
- Portfolio case study
- Questions and ideas in GitHub Discussions
- Bug reports in GitHub Issues
Frequently Asked Questions
Do I need to know Ktor?
No. Ktor knowledge makes the structure instantly familiar, but Churust’s API stands on its own. The prelude, the builder, typed handlers, and four examples cover the path from a first route to a real service.
Is Churust easy to learn if I am new to Rust web development?
That is exactly who the API is designed for. Start with Churust::server(), add routes, then install features as the service needs them. You can use typed extractors most of the time and reach for Call when you need more control.
You still need to learn Rust. Churust does not hide ownership, async functions, or result types. What it removes is the need to learn a fragmented framework vocabulary at the same time.
Is Churust secure?
Churust is designed with a secure default posture and documents the exact limits it enables. It builds on hyper, tokio, and rustls for the protocol, runtime, and TLS layers, then adds framework-level limits and checks around them.
No framework can make an application automatically secure. You still need proper secret storage, authorization rules, dependency updates, TLS configuration, and application-specific validation. Churust makes the common safe choices the starting point instead of an optional hardening guide.
Can I use it for a real service?
Version 0.3 is built for that job: secure defaults, graceful shutdown, streaming, observability, authentication, sessions, modern HTTP, a broad test suite, and a release process that checks every feature.
It is also pre-1.0. Pin the version, read the changelog before upgrading, test your own threat model and traffic patterns, and decide whether the current API stability fits your project.
Does Churust implement HTTP and TLS itself?
No. hyper parses and serves HTTP, tokio runs the async work, and rustls provides optional TLS. Churust owns the application layer: routing, extractors, plugins, configuration, limits, sessions, responses, and developer experience.
Why are features opt-in?
Because an API server does not need templates, a static site does not need JWT, and an internal service may not need WebSockets or HTTP/3. Opt-in features keep compile times, dependencies, and protocol surface aligned with the application you are actually building.
Where this leaves you
Churust is the framework I wanted when moving between Kotlin and Rust: familiar enough to be productive quickly, explicit enough to still feel like Rust, secure before the hardening checklist, and complete enough that a real service does not turn into dependency archaeology.
If you know Ktor, the first application should feel familiar.
If you do not, it should still feel obvious.
Install churust = "0.3", follow the user guide, build one route, and tell me where the experience can become even better. The framework is open source, actively maintained, and ready for developers who want Rust on the server without giving up a clear, approachable API.
Happy coding!
David Cruz
davthecoder.com
Loading comments…