This guide takes you from zero to a running Autumn web app with routes, a
database, HTML templates, interactive UI, and the published autumn-web 0.4
release line. Budget about 30 minutes.
Autumn is a convention-over-configuration web framework for Rust, built on Axum. It bundles Diesel (database), Maud (HTML), Tailwind CSS (styling), htmx (interactivity), actuator endpoints, profile-aware configuration, and newer CLI workflows behind a Spring Boot-style developer experience.
Version note: This is the published-user path for
autumn-web0.4.x andautumn-cli0.4.x as of 2026-05-11. If you are contributing from a source checkout, use the local development commands below only after confirming the workspace version still matches this guide.
Prerequisites
- Rust 1.88.0+ (edition 2024) -- install via rustup
- PostgreSQL -- only needed if you want database features; Autumn runs fine without one
- A terminal and a text editor
Verify your Rust toolchain:
rustc --version # 1.88.0 or later
cargo --version
Install the CLI
Autumn ships a small CLI for project scaffolding and tooling setup. Install the published CLI from crates.io:
cargo install autumn-cli --version 0.6.0
For local development only, from an Autumn source checkout, install the CLI you just built instead:
cargo install --path autumn-cli
This gives you the autumn binary with the core workflow commands:
| Command | What it does |
|---|---|
autumn doctor | Diagnose your environment before first run |
autumn new | Scaffold a new project |
autumn setup | Download Tailwind CSS (with checksum verify) |
autumn dev | Run the dev server with file watching |
autumn build | Pre-render #[static_get] routes into dist/ |
autumn migrate | Run migrations or inspect migration status |
autumn seed | Populate the database with representative data |
Run the Doctor
After installing the CLI, the first command to run from any Autumn project
root is autumn doctor. It checks your environment for common first-run
problems and tells you exactly what to fix before you waste time chasing
cryptic errors:
autumn doctor
Sample output on a healthy system:
🍂 autumn doctor
✅ rust_toolchain — rustc 1.88.0 ≥ MSRV 1.88.0
✅ version_compat — autumn-cli 0.6.0 matches autumn-web 0.6.0
✅ autumn_toml — autumn.toml is valid
✅ db_connectivity — Postgres reachable at localhost:5432
✅ pending_migrations — no pending migrations
✅ port_bindable — port 3000 is available
✅ tailwind_binary — target/autumn/tailwindcss is present
✅ stale_artifacts — artifacts look fresh
8 passed, 0 warnings, 0 failed — all clear
If anything is wrong, autumn doctor prints a one-line remediation hint
beneath the failing check.
Exit codes: 0 when all checks pass (warnings are allowed); 1 when any
check fails. Use --strict to treat warnings as failures (useful in CI).
Use --json for machine-readable output:
# CI pre-flight gate (fail on warnings too)
autumn doctor --strict
# Machine-readable output for scripts
autumn doctor --json
Create a Project
autumn new my-app
cd my-app
This generates:
my-app/
Cargo.toml
autumn.toml # framework configuration
Dockerfile # production container image
.dockerignore
build.rs # Tailwind CSS build pipeline
src/
main.rs # your application entry point
static/
css/
input.css # Tailwind directives
tailwind.config.js
migrations/ # Diesel migrations (empty for now)
.gitignore
Project Structure
The files that matter right now:
| File | Purpose |
|---|---|
src/main.rs | Routes and application bootstrap |
autumn.toml | Server, probes, telemetry, database config |
Dockerfile | Multi-stage production image scaffold |
.dockerignore | Keeps local junk out of container builds |
build.rs | Compiles Tailwind CSS on cargo build |
static/ | Auto-served at /static/ (CSS, JS, images) |
migrations/ | Diesel SQL migrations |
Your First Route
Open src/main.rs. The scaffolded app includes a Maud layout, embedded
migrations, and these starter routes:
#[get("/")]
async fn index() -> maud::Markup {
layout("Welcome", maud::html! {
h1 { "Welcome to my-app!" }
p { "Edit " code { "src/main.rs" } " to get started." }
})
}
#[get("/hello")]
async fn hello() -> &'static str {
"Hello, Autumn!"
}
#[get("/hello/{name}")]
async fn hello_name(name: autumn_web::extract::Path<String>) -> String {
format!("Hello, {}!", *name)
}
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index, hello, hello_name])
.migrations(MIGRATIONS)
.run()
.await;
}
The key pieces:
#[get("/path")]-- annotates a handler for GET requests. Also available:#[post],#[put],#[delete].routes![...]-- collects annotated handlers into aVec<Route>.autumn_web::app().routes(...).run().await-- the app builder. Load config, create the database pool, mount routes, start the server..migrations(MIGRATIONS)-- embeds and applies the app's Diesel migrations when a database is configured.#[autumn_web::main]-- sets up the Tokio async runtime. It is a thin wrapper around#[tokio::main].
Handlers are regular async functions. They can return any type that Axum can
turn into an HTTP response: &str, String, Json<T>, Markup (Maud HTML),
or your own impl IntoResponse.
Run It
autumn dev
If you prefer not to use watch mode:
cargo run
You will see log output like:
INFO autumn: Database not configured
INFO autumn: Listening addr=127.0.0.1:3000
Visit http://localhost:3000 -- you should see "Welcome to my-app!". Try http://localhost:3000/hello/world for the path parameter route.
A health check is automatically mounted at
http://localhost:3000/health. Actuator
endpoints are also auto-mounted at
http://localhost:3000/actuator/health,
http://localhost:3000/actuator/info, and
http://localhost:3000/actuator/metrics.
Probe endpoints are also available at /live, /ready, and /startup.
The /health response looks like:
{ "status": "ok", "version": "0.6.0" }
Press Ctrl+C to stop the server (graceful shutdown with a configurable drain timeout).
Production Notes
The generated app starts with local-safe defaults:
- sessions are in-memory unless you switch to Redis
#[scheduled]tasks run in-process- the generated Dockerfile is generic container scaffolding, not a full Kubernetes deployment
Before deploying multiple replicas, you should usually:
- Set
AUTUMN_PROFILE=prod - Configure
/live,/ready, and/startupin your platform probes - Enable OTLP telemetry and point it at your collector
- Move sessions to Redis
- Run migrations as a one-shot job before starting web replicas
The scaffolded Dockerfile and autumn.toml include commented examples for
probes, telemetry, and Redis sessions. For the full deployment story, read the
Cloud-Native Guide.
Path Parameters
Axum-style path parameters use curly braces in the route pattern and the
Path<T> extractor in the handler signature:
use autumn_web::extract::Path;
use autumn_web::get;
#[get("/users/{id}")]
async fn get_user(id: Path<i32>) -> String {
format!("User #{}", *id)
}
Multiple parameters work by destructuring a tuple:
#[get("/orgs/{org}/repos/{repo}")]
async fn get_repo(Path((org, repo)): Path<(String, String)>) -> String {
format!("{org}/{repo}")
}
You can also use Query<T> for query string parameters:
use autumn_web::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct Pagination {
page: Option<u32>,
per_page: Option<u32>,
}
#[get("/items")]
async fn list_items(Query(params): Query<Pagination>) -> String {
let page = params.page.unwrap_or(1);
let per_page = params.per_page.unwrap_or(20);
format!("Page {page}, showing {per_page} items")
}
Set Up the Database
Autumn uses Diesel with
diesel-async and
deadpool for async Postgres connections. Autumn
drives Diesel for you — the steps below stand up and migrate the database using
autumn commands end to end.
1. Install the Diesel CLI
autumn migrate shells out to the Diesel CLI to apply migrations, so install it
once:
cargo install diesel_cli --no-default-features --features postgres
2. Configure the connection
Edit autumn.toml and uncomment the [database] section:
[database]
url = "postgres://localhost/my_app"
pool_size = 10
connect_timeout_secs = 5
url is the single-primary compatibility field. For production-shaped config,
name the write role explicitly:
[database]
primary_url = "postgres://localhost/my_app"
# replica_url = "postgres://localhost:5433/my_app"
primary_pool_size = 10
replica_pool_size = 5
replica_fallback = "fail_readiness"
auto_migrate_in_production = false
Db, transactions, advisory locks, and autumn migrate use the primary role.
The optional replica role is for read paths that can tolerate replica replay
lag according to replica_fallback.
You can also set the primary URL via environment variable, which takes precedence over the TOML file:
export AUTUMN_DATABASE__PRIMARY_URL="postgres://localhost/my_app"
(Note the double underscore __ separating section from field.)
3. Create the database
autumn db create
autumn db create reads the connection you just configured and creates the
database on its server. It is idempotent — run it again and it simply reports
that the database already exists. (Need a clean slate while iterating on your
schema? See autumn db reset below.)
4. Create a migration
autumn generate migration CreateTodos
This emits a timestamped migration directory under migrations/ with up.sql
and down.sql files. Edit the generated up.sql:
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
And the down.sql:
DROP TABLE todos;
Run it:
autumn migrate
autumn migrate applies every pending migration to the primary database and
regenerates src/schema.rs with Diesel's table macro.
Tip — reset the dev database. While iterating on your schema, run
autumn db resetto drop, recreate, migrate, and (when asrc/bin/seed.rsexists) seed the database in a single step. It refuses to run against a production profile unless you pass--force.
Define a Model
Create src/models.rs:
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use crate::schema::todos;
#[derive(Queryable, Selectable, Serialize)]
#[diesel(table_name = todos)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Todo {
pub id: i32,
pub title: String,
pub completed: bool,
pub created_at: chrono::NaiveDateTime,
}
#[derive(Insertable, Deserialize)]
#[diesel(table_name = todos)]
pub struct NewTodo {
pub title: String,
}
Autumn also provides a #[model] attribute macro that auto-derives the
Diesel and Serde traits for you:
use crate::schema::todos;
// Equivalent to the manual derives above (Queryable, Selectable,
// Insertable, Serialize, Deserialize) plus #[diesel(table_name = todos)]
#[autumn_web::model(table = "todos")]
pub struct Todo {
pub id: i32,
pub title: String,
pub completed: bool,
pub created_at: chrono::NaiveDateTime,
}
If you omit the table = "..." argument, the table name is inferred from the
struct name: BlogPost becomes blog_posts, User becomes users.
Add the required dependencies to Cargo.toml:
[dependencies]
autumn-web = "0.6"
chrono = { version = "0.4", features = ["serde"] }
diesel = { version = "2", features = ["postgres", "chrono"] }
diesel-async = { version = "0.8", features = ["postgres"] }
serde = { version = "1", features = ["derive"] }
Don't forget to declare the modules in main.rs:
mod models;
mod schema;
Query the Database
Use the Db extractor to get an async Postgres connection from the pool.
It implements Deref<Target = AsyncPgConnection>, so you can pass &mut *db
directly to Diesel queries:
use autumn_web::prelude::*;
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use crate::models::{NewTodo, Todo};
use crate::schema::todos;
#[get("/todos")]
async fn list_todos(mut db: Db) -> AutumnResult<Json<Vec<Todo>>> {
let all_todos = todos::table
.order(todos::created_at.desc())
.select(Todo::as_select())
.load(&mut *db)
.await?;
Ok(Json(all_todos))
}
#[post("/api/todos")]
async fn create_todo(mut db: Db, body: Json<NewTodo>) -> AutumnResult<Json<Todo>> {
let created: Todo = diesel::insert_into(todos::table)
.values(&body.0)
.returning(Todo::as_returning())
.get_result(&mut *db)
.await?;
Ok(Json(created))
}
Key points:
Dbis an extractor, not a global. Declare it in your handler signature and Autumn hands you a pooled connection. The connection returns to the pool when the handler completes.AutumnResult<T>isResult<T, AutumnError>. The?operator converts anystd::error::Errorinto anAutumnErrorwith status 500. Diesel errors, I/O errors, serde errors -- they all "just work" with?.mut db: Db-- you needmutbecause Diesel queries take&muton the connection.
Register the new handlers in your main():
mod models;
mod schema;
use autumn_web::routes;
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![list_todos, create_todo])
.run()
.await;
}
Test it:
# Create a todo
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "Write Autumn guide"}'
# List todos
curl http://localhost:3000/todos
Render HTML with Maud
Autumn re-exports Maud, a compile-time HTML
templating library. Return Markup from a handler to send HTML:
use autumn_web::prelude::*;
#[get("/")]
async fn index() -> Markup {
html! {
(autumn_web::PreEscaped("<!DOCTYPE html>"))
html lang="en" {
head {
meta charset="utf-8";
title { "My App" }
link rel="stylesheet" href="/static/css/autumn.css";
}
body {
h1 { "Welcome to my app" }
p { "Built with Autumn." }
}
}
}
}
Maud syntax in brief:
| Maud | HTML output |
|---|---|
h1 { "Hello" } | <h1>Hello</h1> |
div class="box" { "content" } | <div class="box">content</div> |
input type="text" name="q"; | <input type="text" name="q"> |
(variable) | Escaped interpolation |
(PreEscaped(raw_html)) | Unescaped interpolation |
@if cond { ... } @else { ... } | Conditional rendering |
@for item in &items { ... } | Loop rendering |
Extract reusable layouts into functions:
fn layout(title: &str, content: Markup) -> Markup {
html! {
(autumn_web::PreEscaped("<!DOCTYPE html>"))
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { (title) }
link rel="stylesheet" href="/static/css/autumn.css";
script src="/static/js/htmx.min.js" {}
}
body class="bg-gray-100 min-h-screen" {
div class="max-w-2xl mx-auto py-10 px-4" {
(content)
}
}
}
}
}
Then use it in handlers:
#[get("/about")]
async fn about() -> Markup {
layout("About", html! {
h1 { "About this app" }
p { "Built with Autumn, Maud, and Tailwind." }
})
}
Style with Tailwind CSS
Autumn integrates Tailwind CSS via a build.rs
that runs the Tailwind standalone CLI at compile time.
1. Download Tailwind
autumn setup
This downloads the platform-specific Tailwind CSS binary to
target/autumn/tailwindcss with SHA-256 checksum verification. Use
autumn setup --force to re-download.
2. Write Tailwind classes in your templates
The build.rs scans src/**/*.rs for class names, so Tailwind utility
classes inside Maud templates are automatically picked up:
html! {
div class="max-w-2xl mx-auto py-10 px-4" {
h1 class="text-3xl font-bold text-gray-800" { "Styled heading" }
p class="text-gray-500 mt-2" { "A paragraph with Tailwind styles." }
}
}
3. Build
cargo build
The build script compiles static/css/input.css (which contains Tailwind
directives) into static/css/autumn.css. This file is auto-served at
/static/css/autumn.css.
Your input.css starts with:
@tailwind base;
@tailwind components;
@tailwind utilities;
Add custom CSS below the directives as needed.
Skipping Tailwind: If you do not need CSS, delete
build.rsand the Tailwind-related files. Autumn runs fine without them.
Add Interactivity with htmx
Autumn bundles htmx and auto-serves it at
/static/js/htmx.min.js. Include the script tag in your layout, then use
htmx attributes in your Maud templates.
Here is a toggle button that updates a todo without a full page reload:
use autumn_web::prelude::*;
use autumn_web::extract::Path;
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use crate::models::Todo;
use crate::schema::todos;
/// Render a single todo item with htmx controls.
fn todo_item(todo: &Todo) -> Markup {
let title_class = if todo.completed {
"line-through text-gray-400"
} else {
"text-gray-800"
};
html! {
li id=(format!("todo-{}", todo.id))
class="flex items-center gap-3 p-3 bg-white rounded shadow" {
// Toggle button -- POST via htmx, swap this <li> with the response
button hx-post=(format!("/todos/{}/toggle", todo.id))
hx-target=(format!("#todo-{}", todo.id))
hx-swap="outerHTML" {
@if todo.completed { "\u{2713}" } @else { "\u{25CB}" }
}
span class=(title_class) { (todo.title) }
// Delete button -- returns empty string, htmx removes the element
button hx-delete=(format!("/todos/{}", todo.id))
hx-target=(format!("#todo-{}", todo.id))
hx-swap="outerHTML" {
"\u{00D7}"
}
}
}
}
/// Toggle completion status -- returns the updated HTML fragment.
#[post("/todos/{id}/toggle")]
async fn toggle(id: Path<i32>, mut db: Db) -> AutumnResult<Markup> {
let todo: Todo = todos::table
.find(*id)
.select(Todo::as_select())
.first(&mut *db)
.await
.map_err(AutumnError::not_found)?;
diesel::update(todos::table.find(*id))
.set(todos::completed.eq(!todo.completed))
.execute(&mut *db)
.await?;
let updated: Todo = todos::table
.find(*id)
.select(Todo::as_select())
.first(&mut *db)
.await
.map_err(AutumnError::not_found)?;
Ok(todo_item(&updated))
}
/// Delete a todo -- returns empty string so htmx removes the element.
#[delete("/todos/{id}")]
async fn delete_todo(id: Path<i32>, mut db: Db) -> AutumnResult<String> {
diesel::delete(todos::table.find(*id))
.execute(&mut *db)
.await?;
Ok(String::new())
}
The htmx attributes you will use most often:
| Attribute | Purpose |
|---|---|
hx-get | Issue a GET request to the URL |
hx-post | Issue a POST request to the URL |
hx-delete | Issue a DELETE request to the URL |
hx-target | CSS selector for the element to update |
hx-swap | How to insert the response (outerHTML, innerHTML, beforeend, etc.) |
hx-trigger | Event that triggers the request (default: natural event) |
The pattern: your handler returns an HTML fragment (not a full page), htmx swaps it into the DOM. No JavaScript required.
Plain HTML forms targeting PUT/PATCH/DELETE
When you want the same edit and delete flows to work with JavaScript
disabled (or before htmx loads), submit a plain <form> and include a
hidden _method field. Autumn rewrites the request to the declared
HTTP method before route matching, so your #[put], #[patch], and
#[delete] handlers stay semantically honest:
use autumn_web::form::method_input;
use autumn_web::prelude::*;
use autumn_web::security::CsrfToken;
#[get("/todos/{id}/edit")]
async fn edit_form(id: Path<i32>, csrf: Option<CsrfToken>) -> Markup {
html! {
form method="post" action=(format!("/todos/{}", *id)) {
// Hidden field. Autumn rewrites this POST to DELETE.
(method_input("DELETE"))
@if let Some(token) = csrf.as_ref() {
input type="hidden" name="_csrf" value=(token.token());
}
button type="submit" { "Delete" }
}
}
}
A few notes:
- The override is honoured only for
POSTrequests withContent-Type: application/x-www-form-urlencoded. Headers likeX-HTTP-Method-Overrideare intentionally not enabled by default. - It is also enforced as same-origin: the request must carry
Sec-Fetch-Site: same-originornone(sent by every browser since ~2020), or — forsame-siteor whenSec-Fetch-Siteis absent — itsOriginheader must match the request's scheme, host, and port. This is stricter thansame-sitealone, becausesame-siteaccepts sibling subdomains under the same registrable domain (e.g.evil.example.com->app.example.com). When running behind a TLS-terminating reverse proxy, the scheme is read fromX-Forwarded-Proto(leftmost client-facing value); the host is read fromX-Forwarded-Hostif surfaced, otherwise fromHost. Requests that don't meet these conditions are forwarded as the originalPOSTso a cross-origin form can never reach a route declared only as#[delete]. Theautumn_web::test::TestAppform()helper setsSec-Fetch-Site: same-originautomatically. - Unknown override values (anything other than
PUT,PATCH,DELETE, case-insensitive) reject with400 Bad Requestbefore your handler runs. - Form-urlencoded bodies larger than 2 MiB are rejected with
413 Payload Too Largeso an oversized form with_method=DELETEisn't silently demoted to aPOST(body-size-driven semantics). Usemultipart/form-datafor large submissions. - CSRF still treats the transport
POSTas unsafe — an overriddenDELETEwithout a valid token returns403 Forbiddenjust like any other mutating POST. autumn routesand/actuator/routeskeep reporting the declared method (DELETE,PUT,PATCH) so route listings, OpenAPI docs, and log filters stay accurate.
If you build the form through ChangesetForm::form_tag, just pass the
declared method and the helper handles the override and CSRF inputs
for you:
form.form_tag("/todos/42", "delete", html! { button { "Delete" } })
// Renders: <form method="post" action="/todos/42">
// <input type="hidden" name="_method" value="DELETE">
// <input type="hidden" name="_csrf" value="...">
// <button>Delete</button>
// </form>
Error Handling
AutumnResult<T> is Result<T, AutumnError>. Every handler that can fail
should return this type.
The ? operator
Any std::error::Error converts to AutumnError with HTTP 500 automatically:
#[get("/users")]
async fn list_users(mut db: Db) -> AutumnResult<Json<Vec<User>>> {
let users = users::table.load(&mut *db).await?; // 500 on failure
Ok(Json(users))
}
Status refinement
For expected errors, use the status constructors:
#[get("/users/{id}")]
async fn get_user(id: Path<i32>, mut db: Db) -> AutumnResult<Json<User>> {
let user = users::table
.find(*id)
.first(&mut *db)
.await
.map_err(AutumnError::not_found)?; // 404
Ok(Json(user))
}
Available constructors:
| Method | HTTP Status |
|---|---|
AutumnError::not_found(e) | 404 Not Found |
AutumnError::bad_request(e) | 400 Bad Request |
AutumnError::unprocessable(e) | 422 Unprocessable Entity |
err.with_status(StatusCode::FORBIDDEN) | Any status code |
Error responses are JSON:
{
"error": {
"status": 404,
"message": "Record not found"
}
}
Configuration
Autumn uses a five-layer configuration system:
- Framework defaults -- compiled into the binary, zero-config start
- Profile smart defaults -- built-in
dev/prodbehavior autumn.toml-- project-level overridesautumn-{profile}.toml-- profile-specific overridesAUTUMN_*environment variables -- deployment overrides (highest priority)
autumn.toml reference
[server]
host = "127.0.0.1" # default
port = 3000 # default
shutdown_timeout_secs = 30 # default, seconds to drain in-flight requests
# max_concurrent_requests = 256 # unset by default (unlimited); caps in-flight
# requests, shedding the excess with a 503
[database]
primary_url = "postgres://user:pass@localhost:5432/my_app"
# url = "postgres://user:pass@localhost:5432/my_app" # legacy single-primary alias
# replica_url = "postgres://user:pass@localhost:5433/my_app"
pool_size = 10 # default, max connections per role
# primary_pool_size = 10
# replica_pool_size = 5
replica_fallback = "fail_readiness" # or "primary"
connect_timeout_secs = 5 # default
auto_migrate_in_production = false
[log]
level = "info" # default, supports tracing filter syntax
format = "Auto" # Auto | Pretty | Json
[health]
path = "/health" # default
[actuator]
sensitive = false # prod default; dev smart defaults enable sensitive endpoints
Environment variable overrides
Every config field can be overridden via environment variables. The pattern is
AUTUMN_SECTION__FIELD (double underscore separates section from field):
| Variable | Overrides |
|---|---|
AUTUMN_SERVER__PORT | server.port |
AUTUMN_SERVER__HOST | server.host |
AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS | server.shutdown_timeout_secs |
AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS | server.max_concurrent_requests |
AUTUMN_DATABASE__URL | database.url |
AUTUMN_DATABASE__PRIMARY_URL | database.primary_url |
AUTUMN_DATABASE__REPLICA_URL | database.replica_url |
AUTUMN_DATABASE__POOL_SIZE | database.pool_size |
AUTUMN_DATABASE__PRIMARY_POOL_SIZE | database.primary_pool_size |
AUTUMN_DATABASE__REPLICA_POOL_SIZE | database.replica_pool_size |
AUTUMN_DATABASE__REPLICA_FALLBACK | database.replica_fallback |
AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS | database.connect_timeout_secs |
AUTUMN_LOG__LEVEL | log.level |
AUTUMN_LOG__FORMAT | log.format |
AUTUMN_HEALTH__PATH | health.path |
AUTUMN_PROFILE | active profile |
Profiles resolve in this order:
AUTUMN_PROFILE--profile <name>- debug/release auto-detection (
devfor debug,prodfor release)
That means you can keep shared defaults in autumn.toml, put local dev
settings in autumn-dev.toml, and override the final few things in CI or
deployment with env vars.
Log format behavior
| Format | Behavior |
|---|---|
Auto | Pretty in development, JSON when AUTUMN_ENV=production |
Pretty | Always human-readable, colorized |
Json | Always structured JSON |
Running without a database
If you omit the [database] section (or leave both primary_url and url
unset), Autumn starts without a database pool. Handlers that use Db will return 503 Service
Unavailable. This is useful for static sites, APIs that do not need a
database, or during early development.
Escape hatch: mounting raw Axum routers
When route macros are enough, prefer them -- you keep Autumn's discovery conventions and the codebase stays more uniform.
When you need Axum-native composition (for example, mounting a third-party
router like GraphQL), use .merge() or .nest():
use autumn_web::prelude::*;
use autumn_web::AppState;
#[get("/")]
async fn index() -> &'static str { "ok" }
#[autumn_web::main]
async fn main() {
let graphql = axum::Router::<AppState>::new()
.route("/graphql", axum::routing::get(|| async { "graphql endpoint" }));
autumn_web::app()
.routes(routes![index]) // Autumn-managed routes
.merge(graphql) // Raw Axum routes on the same app
.run()
.await;
}
Use .merge() for direct mounting and .nest("/prefix", router) when you want
all routes under a prefix (for example /api/v2).
Merged/nested routers share the same AppState and still pass through Autumn's
global middleware (including X-Request-Id response headers). Avoid defining
the same method+path in both managed and raw routers -- Axum treats overlaps as
an error during router construction.
Route collision diagnostics
Autumn preflights route registration and refuses to start when it can already
prove a collision. Instead of an Axum panic mid-mount, the framework returns a
structured RouterBuildError before any router is mounted, so the failure
mode is a clean error message that names the offending handlers rather than a
half-configured process crashing at startup:
-
FrameworkRouteOverlap-- a user route lands on a path a framework route already owns (probes, actuator,X-Request-Id, dev live-reload). -
OpenApiPathCollision(featureopenapi) -- anopenapi_json_pathorswagger_ui_pathcollides with aGETroute Autumn already owns; each side is named so you can fix the config or the route. -
DuplicateUserRoute(issue #1012) -- two user- or plugin-registered routes resolve to the same(method, path)after.scoped(prefix, …)prefix resolution (including#[repository]-generated API routes). The error names both handlers, the HTTP method, and the path:Textduplicate user route: "list_posts_v1" and "list_posts_v2" both resolve to GET "/api/posts"; choose a different path for one of them or remove the duplicate registrationDistinct methods on the same exact path (
GET /admin+POST /admin, orGET /users/{id}+POST /users/{id}) are NOT flagged -- Axum merges them into a singleMethodRoutercleanly. -
ConflictingRouteShape(issue #1012) -- two routes whose different path templates resolve to overlapping shapes that Axum's matcher cannot tell apart. Path-shape conflict detection is delegated to matchit, the exact routing engine Axum 0.8 uses, so the preflight mirrors Axum's real accept/ reject behavior precisely -- including every edge case: capture-name differences (/users/{id}vs/users/{slug}), a normal capture versus a catch-all at the same position (/u/{id}vs/u/{*rest}), a catch-all versus a dynamic descendant (/cmd/{tool}/{sub}vs/cmd/{*path}), and mixed literal+capture segments (/file.{ext}vs/file.{kind}). Axum's matchit router rejects the second template as a conflict before method merging, so unlike an exact-duplicate path these can never coexist regardless of HTTP method --GET /users/{id}+POST /users/{slug}is still a conflict. The error names both handlers and both original templates:Textconflicting route shapes: "show_user" ("/users/{id}") and "create_user" ("/users/{slug}") resolve to the same Axum path shape but use different path templates; axum's matchit router rejects this as a route conflict regardless of HTTP method — rename the captures so both use the same template, or make their static paths distinctBecause the check is matchit, it never over-flags what Axum would accept: a static segment and a capture at the same position (
/users/me+/users/{id}) coexist, and escaped literal braces ({{/}}, e.g. the static path/{{foo}}) are not captures, so/{{foo}}and/{{bar}}remain distinct static routes and build cleanly.
Opaque routers registered via .merge(router) or .nest(prefix, router)
cannot be introspected through Axum's public API, so a collision that lives
inside one of those routers still surfaces as an Axum startup panic. The
duplicate-route preflight emits a tracing::warn! in that case ("check
skipped") so operators know the check didn't cover that code path -- keep
merged/nested raw routers on paths disjoint from your Autumn-managed routes.
What's Next?
You now have a working Autumn application with routes, database access, HTML rendering, Tailwind styling, htmx interactivity, health checks, and actuator endpoints.
Here are some things to explore:
- Organize routes into modules -- call
.routes()multiple times on the app builder to compose route groups:Rustautumn_web::app() .routes(routes![index]) .routes(routes![list_todos, create_todo, toggle, delete_todo]) .run() .await; - Use
Form<T>for HTML form submissions instead of JSON:Rustuse autumn_web::extract::Form; #[post("/todos")] async fn create(mut db: Db, form: Form<NewTodo>) -> AutumnResult<Markup> { // form.0 is the deserialized NewTodo // ... } - Check
/healthand/actuator/*--/healthgives a small health response, while actuator adds info, metrics, env/configprops, loggers, and scheduled task visibility depending on the active profile. - Localize your UI -- enable the opt-in
i18nfeature flag onautumn-weband drop translation files ati18n/<locale>.ftl. See the i18n guide for the convention, theLocaleextractor, and thet!()macro. - Inspect request IDs -- every response includes an
X-Request-Idheader (UUID v4) for log correlation. - Look at the example apps --
examples/todo-app,examples/blog,examples/bookmarks, andexamples/wikieach exercise different parts of the current framework.
Autumn is still pre-1.0 and evolving quickly. File issues, break glass when you need Axum escape hatches, and ship something.