This walkthrough takes you from an empty directory to a deployed blog: posts, an admin UI, a JSON API, JWT authentication, and tests. End to end.
Time: ~45 minutes for the full tour, ~10 minutes if you just want to see it running.
Runnable version: every step below is mirrored in a tested, compilable example at
crates/rustango/examples/getting_started_blog. If a step ever looks off, diff against it.
Two different questions, and the docs used to answer only the second one.
Rust is assumed. Not expert Rust, but you should be comfortable with structs,
traits, Result and ?, and enough async/.await to read a function without
looking things up. If that is not you yet, the Rust Book
comes first; this guide will not teach the language underneath it.
Web backend experience is not assumed. If you have never built a web API, start with Web API basics in the glossary. It is a five-minute primer on requests, routes, handlers and migrations, and it is written for exactly this gap. Come back here afterwards.
No other framework is assumed. Every step explains itself, and where a term is doing real work, the glossary defines it in plain language.
| Tool | Why | Install |
|---|---|---|
| Rust 1.88+ | Compiler | https://rustup.rs |
| A database | This guide uses Postgres | see Choosing a database below |
psql (optional) |
Inspect DB | brew install libpq / apt install postgresql-client |
rustc --version # should print 1.88+Docker is not required. This guide reaches for it because one command gives you a throwaway Postgres, but nothing in rustango depends on it. Pick whichever row fits your machine — everything after this step is identical:
| You want | Do this | Notes |
|---|---|---|
| No database server at all | Run with SQLite (below) | Nothing to install. Best for learning the framework. |
| Postgres without Docker | Install Postgres natively, then point DATABASE_URL at localhost |
See Native Postgres. |
| Postgres with Docker | docker compose up -d in the generated project |
What the rest of this guide assumes. |
| MySQL or MariaDB | Scaffold with --backend mysql |
See MySQL. |
Whichever you pick, the only thing that changes is DATABASE_URL. Here is the shape of each:
DATABASE_URL=postgres://user:password@localhost:5432/myblog_dev
DATABASE_URL=mysql://user:password@localhost:3306/myblog_dev
DATABASE_URL=sqlite://myblog_dev.db?mode=rwcScaffold the project for SQLite and there is nothing to install, nothing to start, and nothing to edit afterwards:
cargo rustango new myblog --backend sqliteThe generated .env.example, docker-compose.yml and settings tiers are all
written for SQLite, the database is a file created by the first
cargo run -- migrate, and you can skip
Step 4 entirely.
If you already generated a Postgres project and want to switch it, every template keeps all three backends wired up — so it is one flag plus a URL:
cargo run --no-default-features --features sqliteDATABASE_URL=sqlite://myblog_dev.db?mode=rwcmode=rwc tells SQLite to create the file if it does not exist. Everything in
this guide — models, migrations, the admin, the ORM — works unchanged; only
Postgres-specific features (JSONB operators, schema-mode multi-tenancy) do
not apply.
Install Postgres from your package manager (brew install postgresql@16,
apt install postgresql, or the Windows installer at
https://www.postgresql.org/download/windows/), then create the role and
database the generated config expects:
createuser -s rustango # or: CREATE ROLE rustango LOGIN SUPERUSER PASSWORD 'rustango';
createdb myblog_dev -O rustangoThe generated .env.example points at the Docker service hostname. Change the
host to localhost:
# .env — `postgres` is the docker-compose service name; use localhost natively
DATABASE_URL=postgres://rustango:rustango@localhost:5432/myblog_devOn Windows? Docker Desktop's Hyper-V / WSL2 backend is a common source of start-up failures. If it is fighting you, use the SQLite path above to learn the framework and come back to Docker when you are packaging for deployment — that is what the container setup is really for.
Already running Postgres locally? Then port 5432 is taken, and the container quietly loses the race. Your app connects to the local server, which has none of your tables. The error that comes back is not readable, because a non-English server sends its message in its own encoding. Either stop the local service or move the container to another port.
Scaffold with --backend mysql and the generated .env.example,
docker-compose.yml and settings tiers are all written for MySQL:
cargo rustango new myblog --backend mysqlRunning it natively instead of in the container takes a database and a user:
CREATE DATABASE myblog_dev CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'rustango'@'localhost' IDENTIFIED BY 'rustango';
GRANT ALL PRIVILEGES ON myblog_dev.* TO 'rustango'@'localhost';DATABASE_URL=mysql://rustango:rustango@localhost:3306/myblog_devutf8mb4 is worth setting deliberately: MySQL's older utf8 is three bytes and
cannot store an emoji, which surfaces much later as a write that fails on one
row. MariaDB works through the same driver and the same URL scheme.
The scaffolder generates project and app skeletons for you, like rails new.
cargo install cargo-rustangoThis adds the cargo rustango ... subcommand globally. Confirm it's there:
cargo rustango --helpThe scaffolder's own version is the one your project pins, so installing the newest gives you the newest rustango. To generate a project on an older release, install that generator instead (cargo install cargo-rustango --version 0.58.0) — see Scaffolding.
This scaffolds a fresh project, the Rustango equivalent of rails new or composer create-project.
cd ~/projects # wherever you keep code
cargo rustango new myblog # default = fullstack template
cd myblogRun cargo rustango new with no arguments at all and it asks for the
template, the backend and any extra features, then prints the equivalent
command line before creating anything — see Scaffolding.
Here's what was generated:
myblog/
├── Cargo.toml # rustango + axum + sqlx + tokio
├── .env.example # template for DATABASE_URL etc.
├── .gitignore
├── docker-compose.yml # Postgres in a container
├── README.md # project-specific
├── config/ # tiered settings (default + dev/staging/prod)
├── migrations/ # empty — `cargo run -- makemigrations` populates
└── src/
├── main.rs # entry point: `Cli::new().api(urls::api()).run()`
├── models.rs # every #[derive(Model)] lives here
├── views.rs # axum request handlers
└── urls.rs # `pub fn api()` route aggregator
There is a single binary: cargo run boots the HTTP server, and every management verb (migrate, makemigrations, startapp, check, …) flows through the same binary via cargo run -- <verb>. There's no separate manage binary.
Cargo.toml is the dependency manifest (like composer.json or a Gemfile). Open it and confirm rustango is listed under [dependencies].
Confirm the
[features]block — pick a database backend.#[derive(Model)]cfg-gates its generatedFromRow/LoadRelatedimpls on your crate's features (acfginside a derive macro resolves against the destination crate, not Rustango), so a backend feature must be enabled here or the first model won't compile. A current scaffold includes:[features] default = ["postgres"] # the backend `cargo run` uses postgres = ["rustango/postgres"] sqlite = ["rustango/sqlite"] mysql = ["rustango/mysql"]If your generated
Cargo.tomlhas no[features]block (an oldercargo-rustango), add the one above by hand — that always fixes it. Without it the build fails with "the trait bound…: MaybePgFromRowis not satisfied" plus a tell-talewarning: unexpected cfg condition value: postgres.
Configuration lives in a .env file. Copy the template:
cp .env.example .envThe generated .env is Docker-friendly out of the box. Because we'll run cargo on the host (not inside the dev container), change the database host from postgres to localhost:
DATABASE_URL=postgres://rustango:rustango@localhost:5432/myblog_dev
RUSTANGO_BIND=0.0.0.0:8080
RUSTANGO_APEX_DOMAIN=localhostThe credentials, port, and database name (myblog_dev) already match the docker-compose.yml Postgres service, so you don't need to touch those.
RUSTANGO_SESSION_SECRET signs sessions and tokens. It is left commented out in the generated .env.example, and for development you can leave it that way: the first boot generates a key into ./var/ and reuses it, so restarts don't sign you out.
For production set a real one — 32 bytes of base64, from your secret manager or:
openssl rand -base64 32 # paste output as RUSTANGO_SESSION_SECRET valueA value that is not 32 bytes of base64 cannot be used. The server says so on boot and falls back to the generated key rather than failing, so watch for that warning if you set one and sessions behave as though you had not.
Using SQLite? Skip this step — there is no server to start. Make sure
.envhasDATABASE_URL=sqlite://myblog_dev.db?mode=rwcand add--no-default-features --features sqliteto everycargo runbelow.Using a native Postgres? It is already running as a service; just confirm
psql "$DATABASE_URL" -c "SELECT version();"succeeds, then skip ahead.
The project ships with a docker-compose.yml that runs Postgres in a container, so you don't install a database by hand. We'll run the app itself with cargo on the host, so start just the postgres service in the background (the compose file also defines an optional rust dev-container that would otherwise bind port 8080):
docker compose up -d postgresConfirm it's running:
docker compose ps
psql "$DATABASE_URL" -c "SELECT version();" # should print Postgres versionMigrations create your database tables, same idea as php artisan migrate or rails db:migrate. Run them once to set up the framework's own tables:
cargo run -- migrateThe first compile takes ~2 minutes (Rust builds everything from source). A fresh project ships no migration files of its own yet, but migrate is not a no-op: it first generates the framework's own migrations from the compiled models and applies them, so you'll see a few applied … lines rather than nothing to migrate. (That message appears only once everything — framework and project — is already up to date.) It also creates the audit-log table, so audited models work the moment you add them. You generate your first project migration in Step 9.
Check the migration state:
cargo run -- showmigrationsOn a fresh project this prints (no migrations in ./migrations). Once you create a model and run makemigrations (Step 9), each applied migration shows an [X] here.
Start the server to make sure everything is wired up.
cargo runYou'll see:
listening on http://0.0.0.0:8080
Open http://localhost:8080 in your browser. The scaffold ships a simple root handler (views::index) that greets you with Hello from Rustango! and a link to the admin — that confirms Rustango is running. (Projects that don't define their own / route get a built-in welcome page instead, via Cli::with_welcome().)
Press Ctrl-C to stop.
An "app" is a self-contained feature module. Your blog app will hold the Post model, its routes, and its templates.
cargo run -- startapp blogThis writes:
src/blog/
├── mod.rs
├── models.rs # a starter model named after the app (you'll replace it)
├── views.rs # axum handlers
├── urls.rs # blog-specific routes (pub fn api())
└── tests.rs # in-process router + inventory smoke tests
startapp wires the new module in for you: it declares mod blog; in src/main.rs and inserts a .merge(crate::blog::urls::api()) line into the api() aggregator in src/urls.rs, so the blog's routes compose into the app automatically. No manual module registration needed.
A model is a database table described as a Rust struct. Open src/blog/models.rs and define your Post. (For the full reference — every field type, custom primary keys, and all attributes — see the Models guide.)
use rustango::{Auto, Model};
use chrono::{DateTime, Utc};
#[derive(Model, Clone, Debug)]
#[rustango(
table = "posts",
display = "title",
admin(
list_display = "id, title, status, published_at",
search_fields = "title, body",
list_filter = "status, author_id",
ordering = "-published_at",
),
audit(track = "title, body, status"),
index("status, published_at"),
)]
pub struct Post {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 200)]
pub title: String,
pub body: String,
#[rustango(max_length = 20, default = "'draft'")]
pub status: String, // draft | published
pub author_id: i64,
#[rustango(auto_now_add)]
pub published_at: Auto<DateTime<Utc>>,
#[rustango(soft_delete)]
pub deleted_at: Option<DateTime<Utc>>,
}A few Rust things to note:
#[derive(Model, ...)]is a derive macro: it auto-generates code for the struct, the way a class decorator or base class would in other frameworks. DerivingModelis what gives the struct its query methods.Auto<i64>marks a field the database fills in for you (an auto-incrementingi64integer), like an auto primary key.Option<...>means "this value may be absent."Option<DateTime<Utc>>is a timestamp that can be null, sodeleted_atis empty until the row is soft-deleted.- The
#[rustango(...)]attributes configure each field (max length, defaults, indexes) and theadmin(...)block sets up the admin UI columns and filters.
Now turn that model into a real table. First, generate the migration from your model:
cargo run -- makemigrationsYou'll see something like:
wrote ./migrations/0001_create_item_and_posts_and_rustango_admin_users_etc.json
+ CreateTable("item")
+ CreateTable("posts")
+ CreateTable("rustango_admin_users")
+ CreateTable("rustango_content_types")
+ CreateIndex { table: "posts", columns: ["status", "published_at"], ... }
This first migration creates your models — posts, plus the starter item model the scaffold shipped in src/models.rs — alongside the framework's own admin and content-type tables. Open the JSON if you like: it carries the operations plus a full schema snapshot.
Apply it to the database:
cargo run -- migrateConfirm the table exists:
psql "$DATABASE_URL" -c "\d posts"Let's read and write rows from code. The ORM lets you work with database rows as Rust structs instead of raw SQL.
Temporarily edit src/main.rs to run a quick create-and-read test before booting the server. Replace the Cli body with an ad-hoc ORM smoke test (keep the scaffolder's #[rustango::main] and the mod declarations at the top of the file):
mod blog;
mod models;
mod urls;
mod views;
use crate::blog::models::Post;
use rustango::sql::{FetcherPool, Pool};
use rustango::{Auto, Model};
#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = dotenvy::dotenv();
let pool = Pool::connect(&std::env::var("DATABASE_URL")?).await?;
// CREATE
let mut p = Post {
id: Auto::default(),
title: "First post".into(),
body: "Hello, world.".into(),
status: "draft".into(),
author_id: 1,
published_at: Auto::default(),
deleted_at: None,
};
p.save_pool(&pool).await?;
println!("created post id = {}", p.id.get().copied().unwrap());
// READ
let posts = Post::objects().fetch(&pool).await?;
for post in &posts {
println!("- {}", post.title);
}
Ok(())
}What's happening here, in plain terms:
poolis the shared database connection pool. You pass a reference to it (&pool) into query calls instead of opening a new connection each time.- Database calls are asynchronous, so each one ends in
.await— that pauses until the result comes back, then continues. The?after an.awaitsays "if this errored, stop and return the error." mainreturns aResult, Rust's success-or-error type, which is why?and the closingOk(())work.- To save a row, call
.save_pool(&pool)on it. To read rows, build a query withPost::objects()and run it with.fetch(&pool)— with no filters, that reads every row. .fetch(…)comes from theFetcherPooltrait, which is why the imports bring it in. Without that line the method does not exist and the compiler says so without explaining why.- These are the multi-backend calls, and everything above compiles unchanged on all three databases. There are also
.save(&pool)and.fetch_on(&pool), which take a driver-specificsqlx::PgPooland exist only when thepostgresfeature is on. Prefer the multi-backend pair unless you deliberately want one database. See the ORM guide.
Run it:
cargo runYou should see your new post id and the rows read back. Restore src/main.rs to its scaffolded server shape once you've confirmed it works — the next step builds on that.
Rustango ships a generated admin UI for your models — browse, search and edit rows with no code of your own. Building it is two small steps: a helper that turns a pool into an admin router, and one .nest(...) call to mount it.
Add the helper to src/urls.rs yourself — the scaffolder does not generate it, because nothing it generates would call it. The admin_prefix must match the path you'll nest it under in the next step (/admin) so the admin's own links and form actions resolve:
use rustango::admin;
use rustango::sql::Pool;
pub fn admin_router(pool: Pool) -> Router {
admin::Builder::new(pool)
.title("Myblog Admin")
.admin_prefix("/admin") // must match the `.nest("/admin", …)` below
.build()
}Builder::new takes any backend's pool, so this helper names no driver and works on all three.
Then connect a pool in src/main.rs and nest the admin into the API router before handing it to the Cli. Keep the mod blog; line from Step 7 — that's what registers your Post model with the admin:
mod blog;
mod models;
mod urls;
mod views;
#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = dotenvy::dotenv();
let pool = rustango::sql::Pool::connect(&std::env::var("DATABASE_URL")?).await?;
let api = urls::api().nest("/admin", urls::admin_router(pool));
rustango::manage::Cli::new()
.api(api)
.with_health() // /health + /ready endpoints
.run()
.await
}Cli::new()...run() is the same unified dispatcher the scaffolder generated — it still serves every cargo run -- <verb>; you've only enriched the router it serves at runserver time.
Run it:
cargo runOpen http://localhost:8080/admin (no trailing slash). You'll see the admin home with a posts link. Click it to see your draft post in the list, click the post to open its edit form, and save. The audit-trail tab records every write.
A ViewSet exposes a model as a REST API with list, create, retrieve, update, and delete endpoints, all from one declaration.
Scaffold the file, then fill in which fields and behaviors to expose:
cargo run -- make:viewset PostViewSet --model PostEdit src/post_view_set.rs:
use rustango::ViewSet;
use crate::blog::models::Post;
#[derive(ViewSet)]
#[viewset(
model = Post,
fields = "id, title, body, status, author_id, published_at",
filter_fields = "author_id, status",
search_fields = "title, body",
ordering = "-published_at",
page_size = 20,
)]
pub struct PostViewSet;Register the new module by adding mod post_view_set; alongside the other mod declarations at the top of src/main.rs.
Attach the ViewSet's routes to the app's router (the Rustango version of a urls.py or a routes/api.php file). The ViewSet router needs the database pool, so build it in src/main.rs where the pool lives and merge it into the urls::api() aggregator:
let api = urls::api()
.nest("/admin", urls::admin_router(pool.clone()))
.merge(crate::post_view_set::PostViewSet::router("/api/posts", pool));
rustango::manage::Cli::new()
.api(api)
.with_health()
.run()
.await(urls::api() is the aggregator the scaffolder generated; manage startapp merges any sub-app's routes into it the same way.)
Start the server:
cargo runIn another terminal, hit the API with curl:
curl http://localhost:8080/api/posts # list
curl -X POST http://localhost:8080/api/posts \
-H "content-type: application/json" \
-d '{"title":"From API","body":"Yo","status":"published","author_id":1}'
curl http://localhost:8080/api/posts/1 # retrieve
curl "http://localhost:8080/api/posts?search=API&ordering=-id" # search + sort
curl "http://localhost:8080/api/posts?status__ne=draft" # lookup operatorBy default the ViewSet returns every model field. A Serializer lets you control the response shape: hide internal fields, rename them, or mark some read-only.
cargo run -- make:serializer PostSerializer --model PostEdit src/post_serializer.rs:
use rustango::{Auto, Serializer};
use crate::blog::models::Post;
#[derive(Serializer, serde::Deserialize, Default)]
#[serializer(model = Post)]
pub struct PostSerializer {
pub id: Auto<i64>,
pub title: String,
#[serializer(source = "body")] // rename in API
pub content: String,
pub author_id: i64, // writable: NOT NULL with no default
#[serializer(read_only)] // include in GET, ignore in POST/PUT
pub published_at: Auto<chrono::DateTime<chrono::Utc>>,
}Once a serializer is attached, its fields are the whole write surface: anything a client posts that is not listed here is dropped before the INSERT. That is why author_id appears. It is NOT NULL with no default on the model, so leaving it out makes every create fail on the not-null constraint. status can stay out because the model gives it default = "'draft'".
Each serializer field's type mirrors the matching model field, so id and published_at keep their Auto<…> wrapper from the model (an Auto<i64> still serializes to a plain JSON integer). Then register the module by adding mod post_serializer; alongside the other mod declarations in src/main.rs.
Wire the serializer into the ViewSet with the serializer attribute — list, retrieve, and create responses are then rendered through it (the field-level fields projection is bypassed in favor of the serializer's shape):
#[derive(ViewSet)]
#[viewset(
model = Post,
serializer = crate::post_serializer::PostSerializer,
ordering = "-published_at",
)]
pub struct PostViewSet;This works identically on PostgreSQL, MySQL, and SQLite. method / read_only / source / write_only overrides all apply to the response, and request bodies are validated through the serializer too: create / update run its validate() (per-field and cross-field), returning a 400 with one list of messages per field ({field: [messages]}) on failure, and read-only / computed fields a client posts are ignored. (Note: nested / many serializer fields need the related rows loaded via select_related; otherwise they render as their default.) See the ViewSets guide for the full input + output behavior.
JWTs are signed tokens you hand a client after login and check on each request, a common pattern for API auth. Rustango's rustango::jwt module issues and verifies them (HS256) and is on by default — no extra feature flag.
These are fragments, not standalone files: this one goes inside your login handler in src/views.rs, alongside the handlers from Step 6.
Bake the user id (the token's "subject") and any custom claims, like roles, into a signed token, then hand it to the client:
use rustango::jwt::{encode, Claims};
use std::time::Duration;
// Derive the signing key from your session secret.
let secret = std::env::var("RUSTANGO_SESSION_SECRET")?.into_bytes();
let mut claims = Claims::new(user_id.to_string()); // subject = user id
claims.set("roles", vec!["editor"]);
let token = encode(&claims.ttl(Duration::from_secs(900)), &secret)?;
// Send `token` to the client (e.g. in the login response body).This belongs wherever you guard a route — inside a protected handler in src/views.rs, or in an axum extractor / middleware::from_fn layer if you want it applied to a whole subtree.
Decode the token — this checks the signature and expiry — then read the claims back. If it's missing or invalid, reject the request as unauthorized:
use rustango::jwt::decode;
let claims = decode(&access_token, &secret)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
let user_id = claims.subject().ok_or(StatusCode::UNAUTHORIZED)?;
let roles: Vec<String> = claims.get("roles").unwrap_or_default();rustango::jwt issues stateless single tokens. For the full pattern — short-lived access tokens, a long-lived refresh token in an HttpOnly cookie, rotation, and a JTI blacklist for revocation — enable the tenancy feature and use rustango::tenancy::jwt_lifecycle::JwtLifecycle, whose issue_pair_with / verify_access / refresh methods manage the pair for you.
Middleware wraps every request to add cross-cutting behavior. This one goes in src/main.rs: it replaces the let api = ... line from Step 11, so the router is fully assembled before it reaches the Cli. Here you stack request IDs, access logging, rate limiting, CORS, and security headers in one chain. Each .method(...) adds one layer. See the Middleware guide for the full layer catalog and ordering rules.
use rustango::security_headers::{SecurityHeadersLayer, SecurityHeadersRouterExt, CspBuilder};
use rustango::cors::{CorsLayer, CorsRouterExt};
use rustango::rate_limit::{RateLimitLayer, RateLimitRouterExt};
use rustango::access_log::{AccessLogLayer, AccessLogRouterExt};
use rustango::request_id::{RequestIdLayer, RequestIdRouterExt};
use rustango::health::health_router;
use std::time::Duration;
let app = urls::api()
.nest("/admin", urls::admin_router(pool.clone()))
.merge(crate::post_view_set::PostViewSet::router("/api/posts", pool.clone()))
.merge(health_router(pool.clone())) // /health, /ready
.request_id(RequestIdLayer::default())
.access_log(AccessLogLayer::default()) // PII-redacted
.rate_limit(RateLimitLayer::per_ip(60, Duration::from_secs(60)))
.cors(CorsLayer::new()
.allow_origins(vec!["https://app.example.com"])
.allow_methods(vec!["GET", "POST", "PUT", "PATCH", "DELETE"]))
.security_headers(
SecurityHeadersLayer::strict()
.csp(CspBuilder::strict_starter().build()),
);Hand the finished app to the Cli exactly as before — rustango::manage::Cli::new().api(app).with_welcome().run().await — and every request now flows through the full middleware stack.
Rustango includes a test client that drives your router in-process, so you can assert on real HTTP responses without starting a server. Scaffold a test file:
cargo run -- make:test PostSmoke # generates tests/post_smoke.rsThe make:* generators take a PascalCase name; PostSmoke becomes the snake_case file tests/post_smoke.rs.
Edit tests/post_smoke.rs. Integration tests live in a separate crate, so they build the router under test directly from the ViewSet (the same router(...) call you mounted in Step 12b):
use rustango::test_client::TestClient;
use myblog::post_view_set::PostViewSet;
use rustango::sql::Pool;
use serde_json::json;
async fn app() -> axum::Router {
// A test in `tests/` is a separate crate and never runs `main`, so
// nothing has loaded `.env` for it. Without this line `DATABASE_URL`
// is unset and both tests panic before reaching the database.
let _ = dotenvy::dotenv();
let pool = Pool::connect(&std::env::var("DATABASE_URL").unwrap()).await.unwrap();
PostViewSet::router("/api/posts", pool)
}
#[tokio::test]
async fn list_posts_returns_200() {
let client = TestClient::new(app().await);
let response = client.get("/api/posts").send().await;
assert_eq!(response.status, 200);
let v = response.json_value();
assert!(v["results"].is_array());
}
#[tokio::test]
async fn create_post_returns_the_new_object() {
let client = TestClient::new(app().await);
let response = client.post("/api/posts")
// Post the serializer's fields. `status` is omitted because the
// serializer does not list it, so it would be dropped anyway —
// the model's `default = "'draft'"` fills it in.
.json(&json!({
"title": "Test",
"content": "x",
"author_id": 1,
}))
.send().await;
assert_eq!(response.status, 201);
let v: serde_json::Value = response.json();
assert_eq!(v["title"], "Test");
}Three things in that snippet are easy to get wrong, and each produces a different failure:
dotenvy::dotenv()— omit it and both tests fail, before any request is made.content, notbody— the serializer accepts either here, sincesource = "body"keeps the model's name working on input, butcontentis the name your API actually publishes.author_id— omit it and the create test alone fails, with a not-null violation from the database. The list test still passes, because an empty table is a valid empty page.
Pool is the multi-backend pool, and router takes any backend's pool, so this file compiles unchanged on PostgreSQL, MySQL and SQLite.
Heads-up: integration tests in
tests/can onlyuse myblog::…if the crate exposes a library target. A fresh scaffold is binary-only (src/main.rs, nosrc/lib.rs), so add a one-linesrc/lib.rsthat re-exports the modules you want to test —pub mod models; pub mod post_view_set; pub mod urls;— and keep the matchingmod …;lines insrc/main.rs. (If you'd rather not add a lib target, build the router fully inline in the test instead, the waymake:testscaffolds itsapp().)
Run the tests:
cargo test --test post_smokeBefore you deploy, run the built-in checker. It flags common misconfigurations, like a weak RUSTANGO_SESSION_SECRET or an unreachable database.
cargo run -- check --deployIn your local dev environment you'll see something like:
running rustango system check (deploy mode)...
[info] 6 models registered via inventory
[info] database reachable
[info] 1 migration(s) on disk
[info] RUSTANGO_SESSION_SECRET length OK
[info] config tier resolved to `dev`
[warning] RUSTANGO_ENV is unset — set to `prod` so config loaders pick the right tier
[warning] DATABASE_URL points at localhost / 127.0.0.1 — verify this is intended in production
[warning] RUSTANGO_APEX_DOMAIN is unset / `localhost` — set it for tenancy projects
(The exact model/migration counts depend on your project.) Those three warnings are the expected dev-environment ones. In a production setup — RUSTANGO_ENV=prod, a managed-database DATABASE_URL, an apex domain set — they clear and you'll see all checks passed. Fix any remaining warnings or errors before pushing to production.
How you deploy depends on your platform (Fly, Railway, Kubernetes, bare ECS, and so on). The framework-side steps are the same everywhere; the --release flag builds an optimized binary:
# 1. Set production env
export RUSTANGO_ENV=prod
export DATABASE_URL=postgres://prod-host/myblog
export RUSTANGO_SESSION_SECRET=$(openssl rand -base64 32)
# 2. Run migrations
cargo run --release -- migrate
# 3. Audit
cargo run --release -- check --deploy
# 4. Build binary
cargo build --release
# 5. Run with a process supervisor (systemd / docker / k8s)
./target/release/myblogMake sure your reverse proxy:
- Terminates HTTPS
- Forwards
X-Forwarded-Forfor accurate IPs inAccessLogLayer - Forwards
X-Forwarded-Host,X-Forwarded-Proto - Uses
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())soConnectInfois populated for rate limiting + IP filtering
| Topic | Doc |
|---|---|
| Runnable version of this guide | examples/getting_started_blog |
Every manage subcommand |
docs/manage.md |
| ORM cookbook (advanced filters, aggregations, M2M, soft delete) | docs/orm.md |
| Middleware (the full layer catalog + ordering) | docs/middleware.md |
| Performance benchmarks (vs Go) | docs/benchmarks.md |
| API conventions (naming, builder patterns, feature gates) | docs/api-conventions.md |
| Security features in depth | docs/security.md |
| Multi-tenancy | README — Multi-tenancy section |
| API docs | https://docs.rs/rustango |
If you hit something that doesn't work or is unclear, open an issue.
