rustango 0.23.1

Django-shaped batteries-included web framework for Rust: ORM + migrations + auto-admin + multi-tenancy + audit log + auth (sessions, JWT, OAuth2/OIDC, HMAC) + APIs (ViewSet, OpenAPI auto-derive, JSON:API) + jobs (in-mem + Postgres) + email + media (S3 / R2 / B2 / MinIO + presigned uploads + collections + tags) + production middleware (CSRF, CSP, rate-limiting, compression, idempotency, etc.).
Documentation

rustango

A Django-shaped, batteries-included web framework for Rust.

ORM with auto-migrations, multi-tenancy, auto-admin, sessions + JWT + OAuth2/OIDC + HMAC auth, signals, caching, first-class media (Postgres rows + S3/R2/B2/MinIO + presigned uploads + collections + tags), email pipeline (renderer + jobs + Mailable), background jobs (in-mem + Postgres), webhook delivery, OpenAPI 3.1 auto-derive from serializers + viewsets, JSON:API + RFC 7807 Problem Details, scheduled tasks, RFC 6238 TOTP, signed URLs, Prometheus metrics, OTel-shape tracing, distributed locks + rate limits + feature flags, every standard middleware (CSRF, CSP nonce, gzip/deflate, body limit, real-IP, idempotency, maintenance, trailing slash, static files, method override, server-timing, …) — all shipped, all opt-out via cargo features.

[dependencies]
rustango = "0.23"

Table of contents


Quick start

1. Install the scaffolder

cargo install cargo-rustango

2. Create a project

cargo rustango new myblog                   # default: ORM + admin
cargo rustango new myapi --template api     # JSON-only, no admin
cargo rustango new shop --template tenant   # multi-tenancy + operator console

3. First-time setup

cd myblog
cp .env.example .env                        # edit DATABASE_URL
docker compose up -d                        # starts Postgres
cargo run -- migrate                        # apply bootstrap migrations
cargo run                                   # http://localhost:8080

Autoreload during development — recompiles + restarts on every file save:

cargo install cargo-watch
cargo watch -x run

# Or with bacon (faster, nicer UI):
cargo install bacon
bacon run

You should see the scaffolded landing page (src/views.rs::index) at / confirming rustango is wired up. Replace it with your own handler — or mount a real router under / — when you're ready.

4. Add an app + model

cargo run -- startapp blog     # scaffolds src/blog/

Edit src/blog/models.rs:

use rustango::{Auto, Model};
use chrono::{DateTime, Utc};

#[derive(Model, Clone)]
#[rustango(
    table = "posts",
    display = "title",
    admin(list_display = "id, title, published_at", search_fields = "title, body"),
    audit(track = "title, body"),
    index("published_at, author_id"),
)]
pub struct Post {
    #[rustango(primary_key)]
    pub id: Auto<i64>,
    #[rustango(max_length = 200)]
    pub title: String,
    pub body: String,
    pub author_id: i64,
    #[rustango(auto_now_add)]
    pub published_at: Auto<DateTime<Utc>>,
}
cargo run -- makemigrations                 # generates migration JSON
cargo run -- migrate                        # applies it

5. Generate a viewset + serializer

cargo run -- make:viewset PostViewSet --model Post
cargo run -- make:serializer PostSerializer --model Post

Edit the generated files to fill in field lists, then mount in src/urls.rs.


Project layout

A scaffolded project looks like:

myblog/
├── Cargo.toml                  # rustango + axum + tokio + sqlx
├── .env / .env.example         # DATABASE_URL, SECRET_KEY, RUSTANGO_ENV
├── docker-compose.yml          # Postgres in a container for local dev
├── README.md
├── migrations/                 # JSON files written by `cargo run -- makemigrations`
└── src/
    ├── main.rs                 # one binary — HTTP server + manage CLI; declares `mod`s here
    ├── urls.rs                 # top-level Router composition
    ├── models.rs               # OR per-app: src/blog/models.rs
    └── views.rs

Since v0.16 the manage CLI is dispatched from main.rs via rustango::manage::Cli — running cargo run with no args starts the HTTP server, and cargo run -- <verb> dispatches a CLI command. There is no longer a separate src/bin/manage.rs, and the scaffold is binary-only (no src/lib.rs) — apps are declared as mod blog; inside src/main.rs. The scaffolded main.rs is just:

mod models;
mod urls;
mod views;

#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let _ = dotenvy::dotenv();
    rustango::manage::Cli::new().api(urls::api()).run().await
}

The tenant template additionally calls .tenancy() and .migrations_dir(...) on the Cli builder.

Per-app structure (created by cargo run -- startapp <name>):

src/blog/
  ├── mod.rs
  ├── models.rs                 # #[derive(Model)] structs
  ├── views.rs                  # axum handlers
  └── urls.rs                   # router for this app

manage CLI reference

Since v0.16 there is no separate manage binary — the verbs are dispatched by the project's own binary via rustango::manage::Cli, so the canonical invocation is cargo run -- <verb>. Every command has --help. Exit code is non-zero on validation / IO / system-check errors.

Migrations

cargo run -- makemigrations [name]                   # diff registry → next JSON
cargo run -- makemigrations --app <app> [name]       # per-app migration dir
cargo run -- makemigrations --empty <name>           # scaffold for hand-authored data ops
cargo run -- migrate                                  # apply every pending
cargo run -- migrate <target>                         # forward or back to <target>; `zero` wipes
cargo run -- migrate --dry-run                        # print SQL without writing
cargo run -- downgrade [N]                            # step back N (default 1)
cargo run -- showmigrations | status                  # [X] applied / [ ] pending list

Data migrations

cargo run -- add-data-op \
    --sql "UPDATE posts SET slug = lower(title)" \
    --reverse-sql "UPDATE posts SET slug = NULL" \
    --name backfill_post_slugs

cargo run -- add-data-op --to 0003_add_slug --sql "UPDATE posts SET slug = id::text"

Scaffolders

cargo run -- startapp <name> [--with-manage-bin]
cargo run -- make:viewset PostViewSet --model Post
cargo run -- make:serializer PostSerializer --model Post
cargo run -- make:form ContactForm
cargo run -- make:job EmailDigestJob
cargo run -- make:notification WelcomeEmail
cargo run -- make:middleware AuditLog
cargo run -- make:test PostSmoke               # PascalCase — file is post_smoke.rs

System

cargo run -- about                # version, model count, registered apps, DB connectivity
cargo run -- check                # pending migrations, missing models, DB reachable
cargo run -- check --deploy       # + SECRET_KEY length, RUSTANGO_ENV, DATABASE_URL
cargo run -- docs                 # opens https://docs.rs/rustango in browser
cargo run -- version | --version  # framework version

Tenancy (only with tenancy feature)

cargo run -- create-tenant acme --display-name "ACME Corp"
cargo run -- create-operator admin --password letmein
cargo run -- create-user acme alice --password hunter2 --superuser
cargo run -- list-tenants
cargo run -- audit-cleanup --days 90
cargo run -- audit-cleanup --keep-last 50 --tenant acme

ORM cookbook

Model declaration

use rustango::{Auto, Model};
use rustango::sql::ForeignKey;
use chrono::{DateTime, Utc};
use uuid::Uuid;

#[derive(Model, Clone)]
#[rustango(
    table = "posts",
    display = "title",                            // shown when post is FK target
    admin(
        list_display = "id, title, published_at",
        search_fields = "title, body",
        list_filter = "author_id",
        ordering = "-published_at",
        list_per_page = 50,
    ),
    audit(track = "title, body, status"),         // per-field before/after diff
    permissions,                                  // auto-create CRUD codenames
    index("published_at, author_id"),             // composite index
    check(name = "valid_status",
          expr = "status IN ('draft', 'published')"),
    m2m(name = "tags", to = "tags", through = "post_tags",
        src = "post_id", dst = "tag_id"),
)]
pub struct Post {
    #[rustango(primary_key)]
    pub id: Auto<i64>,

    #[rustango(max_length = 200, index)]
    pub title: String,

    pub body: String,
    pub status: String,

    pub author: ForeignKey<Author>,                // typed FK, lazy-loadable

    #[rustango(auto_now_add)]                      // NOW() on INSERT, immutable
    pub created_at: Auto<DateTime<Utc>>,
    #[rustango(auto_now)]                          // NOW() on every save
    pub updated_at: Auto<DateTime<Utc>>,
    #[rustango(soft_delete)]                       // stamp instead of hard-DELETE
    pub deleted_at: Option<DateTime<Utc>>,
    #[rustango(auto_uuid)]                         // server-generated UUIDv4
    pub external_id: Auto<Uuid>,

    #[rustango(default = r#"'{}'::jsonb"#)]
    pub data: serde_json::Value,
}

Querying

use rustango::core::Column as _;
use rustango::sql::Fetcher as _;

// Filter + order
let recent = Post::objects()
    .where_(Post::status.eq("published"))
    .where_(Post::deleted_at.is_null())
    .order_by(Post::published_at, true)            // true = DESC
    .limit(20)
    .fetch(&pool).await?;

// Pagination
let page = Post::objects().page(2, 50).fetch(&pool).await?;

// Aggregation
let count = Post::objects().where_(Post::author_id.eq(42)).count(&pool).await?;
let avg = Post::objects().avg(Post::view_count, &pool).await?;

// IN / NOT IN
let some = Post::objects()
    .where_(Post::id.in_(&[1, 2, 3]))
    .fetch(&pool).await?;

// Pattern lookups
let drafts = Post::objects()
    .where_(Post::title.icontains("draft"))       // ILIKE %draft%
    .fetch(&pool).await?;

// Pre-load FKs (no N+1)
let with_authors = Post::objects()
    .select_related("author")
    .fetch(&pool).await?;

Mutations

// Insert
let mut p = Post {
    id: Auto::default(),
    title: "Hello".into(),
    body: "World".into(),
    status: "draft".into(),
    // ... other fields
};
p.save_on(&pool).await?;
println!("inserted id = {}", p.id.get().copied().unwrap_or(0));

// Update — same `save_on()`, dispatched by Auto<T> PK state
p.title = "Hello world".into();
p.save_on(&pool).await?;

// Bulk insert
Post::bulk_insert_on(&pool, vec![p1, p2, p3]).await?;

// Bulk update
Post::objects()
    .where_(Post::status.eq("draft"))
    .update()
    .set(Post::status, "published")
    .execute_on(&pool).await?;

// Upsert (ON CONFLICT)
post.upsert_on(&pool, &["external_id"]).await?;

// Delete (soft if model has #[rustango(soft_delete)])
p.soft_delete_on(&pool).await?;
p.restore_on(&pool).await?;

Transactions

rustango::sql::transaction(&pool, |conn| async move {
    p1.save_on(&mut *conn).await?;
    p2.save_on(&mut *conn).await?;
    Ok(())
}).await?;

Bi-dialect (Postgres + MySQL) via &Pool

The classic API takes &PgPool (Postgres-only). The v0.23.0 series adds a parallel &Pool API that targets either backend; pick MySQL 8.0+ or Postgres at runtime via the connection URL.

# Cargo.toml — opt in to MySQL alongside the default postgres feature
rustango = { version = "0.23", features = ["mysql"] }
use rustango::sql::{Pool, FetcherPool, CounterPool};

// Connect from URL (postgres:// or mysql://) or from split env vars
// (DB_DRIVER + DB_HOST + DB_PORT + DB_USER + DB_PASSWORD + DB_NAME +
// DB_PARAMS — passwords are auto percent-encoded).
let pool = Pool::connect_from_env().await?;

// Schema bootstrap (CREATE TABLE per registered model — dialect-aware
// type names, BIGINT AUTO_INCREMENT vs BIGSERIAL, JSON vs JSONB, etc.).
rustango::migrate::apply_all_pool(&pool).await?;

// Or run the file-based ledger runner — full Django-shape lifecycle.
rustango::migrate::migrate_pool(&pool, dir).await?;
rustango::migrate::migrate_to_pool(&pool, dir, "0005_x").await?;
rustango::migrate::downgrade_pool(&pool, dir, 1).await?;
rustango::migrate::unapply_pool(&pool, dir, "0005_x").await?;

// Macro-emitted CRUD against either backend (every #[derive(Model)] type).
let mut user = User { id: Auto::Unset, name: "alice".into(), .. };
user.insert_pool(&pool).await?;        // Auto<i64> populated via
                                       // RETURNING (PG) / LAST_INSERT_ID() (MySQL)
user.name = "Alice".into();
user.save_pool(&pool).await?;          // INSERT-or-UPDATE; audited models
                                       // emit a transactional diff audit row
user.delete_pool(&pool).await?;        // DELETE (transactional with audit
                                       // emit when the model is audited)

// QuerySet read path — single-table, select_related joins, prefetch,
// pagination, and aggregates all bi-dialect.
let posts: Vec<Post> = Post::objects()
    .filter(Post::is_published.eq(true))
    .select_related(&[Post::author])   // joins decoded automatically
    .order_by(&[Post::created_at.desc()])
    .limit(20)
    .fetch_pool(&pool).await?;
let n: i64 = User::objects().count_pool(&pool).await?;
let page = rustango::sql::fetch_paginated_pool(
    Post::objects().limit(20).offset(40),
    &pool,
).await?;
let with_kids: Vec<(User, Vec<Post>)> =
    rustango::sql::fetch_with_prefetch_pool::<User, Post>(
        User::objects(),
        "user_id",
        &pool,
    ).await?;

// Cross-table atomicity — open a backend-tagged transaction.
let mut tx = rustango::sql::transaction_pool(&pool).await?;
match &mut tx {
    rustango::sql::PoolTx::Postgres(t) => { /* $1 placeholders */ }
    rustango::sql::PoolTx::Mysql(t)    => { /* ?  placeholders */ }
}
tx.commit().await?;

Operator translations: ILIKELOWER(col) LIKE LOWER(?), IS DISTINCT FROMNOT (col <=> ?), JSONB @>JSON_CONTAINS, JSONB ?/?|/?&JSON_CONTAINS_PATH(col, 'one'|'all', CONCAT('$.', ?)), UPDATE … FROM (VALUES …)UPDATE … INNER JOIN (VALUES ROW(?, ?), …). ON CONFLICT DO UPDATE SET col = EXCLUDED.colON DUPLICATE KEY UPDATE col = VALUES(col) (with target: vec![]; MySQL's upsert can't take a target column list).

MySQL caveats:

  • requires MySQL 8.0+ (window functions for fetch_paginated_pool, JSON column type, VALUES ROW(…) syntax for bulk_update_pool)
  • fetch_paginated_pool uses COUNT(*) OVER () — needs 8.0
  • LAST_INSERT_ID() reports one auto-assigned column per connection, so models with multiple Auto<T> PKs error at runtime on MySQL (Postgres RETURNING is unaffected)

Migration story: the &PgPool API stays exactly as it was — every existing app keeps working unchanged on upgrade. Adopt &Pool at your own pace (or never, if you only target Postgres).

Many-to-many

// All declared via #[rustango(m2m(...))] — junction table auto-created
let tag_ids = post.tags_m2m().all(&pool).await?;
post.tags_m2m().add(42, &pool).await?;
post.tags_m2m().remove(42, &pool).await?;
post.tags_m2m().set(&[1, 2, 3], &pool).await?;     // replace all
post.tags_m2m().clear(&pool).await?;
let has = post.tags_m2m().contains(42, &pool).await?;

ContentTypes — generic relations + composite-key FKs + soft-FK prefetch

Django-shape framework for "any registered model" pointers. Lets one table point at any other model via (content_type_id, object_pk) (comments-on-anything, audit log targets, activity streams, tag generic relations) and lets a single FK constraint span multiple columns. Sub-slices F.1 / F.2 / F.3 of v0.15.0.

Bootstrap

// Registers one row per #[derive(Model)] type in `rustango_content_types`.
// Idempotent — re-runs on a populated DB return Ok(0). Run once at startup
// after `migrate(&pool, dir).await?`.
let inserted = rustango::contenttypes::ensure_seeded(&pool).await?;

Lookups

use rustango::contenttypes::ContentType;

// By Rust type
let ct = ContentType::for_model::<Post>(&pool).await?;       // Option<ContentType>

// By natural key (parsed permission codenames, admin URLs, etc.)
let ct = ContentType::by_natural_key(&pool, "blog", "post").await?;

// By id (FK joins from audit log / permissions / generic FK rows)
let ct = ContentType::by_id(&pool, 7).await?;

// Full listing for admin sidebars / API
let all_cts = ContentType::all(&pool).await?;                 // ordered (app, model)

Composite-key foreign keys (F.2)

Multi-column FKs declared on the model, not the field. Each participating column keeps its plain Rust type — the FK metadata records which columns participate and where they reference.

#[derive(Model)]
#[rustango(
    table = "audit_log",
    fk_composite(
        name = "target",
        to   = "ct_live_pair",
        from = ("entity_table", "entity_pk"),
        on   = ("table_name",   "row_pk"),
    ),
)]
pub struct AuditLog {
    #[rustango(primary_key)]
    pub id: Auto<i64>,
    pub entity_table: String,
    pub entity_pk: i64,
}

// Emits on migrate / apply_all:
//   ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_target_fkey"
//     FOREIGN KEY ("entity_table", "entity_pk")
//     REFERENCES "ct_live_pair" ("table_name", "row_pk");

Both Postgres and MySQL emit the standard composite FK syntax — only identifier quoting differs. Single-column FKs continue to use the existing per-field Relation::Fk machinery; composite FKs sit on ModelSchema.composite_relations so the existing single-FK machinery (admin display, snapshot diff) stays untouched.

GenericForeignKey + soft-FK prefetch (F.3)

use rustango::contenttypes::{GenericForeignKey, prefetch_soft, prefetch_generic};

// `GenericForeignKey` — a runtime pointer at any registered model's row.
let gfk = GenericForeignKey::for_target::<Post>(&pool, post_id).await?;
// gfk.content_type_id  ← Post's ContentType row id
// gfk.object_pk        ← post_id

Soft-FK prefetch — for integer columns that conceptually point at another model's PK without a declared Relation::Fk (audit log entity_pk, denormalized snapshots, optional cross-app refs):

let parent_pks: Vec<i64> = posts.iter().map(|p| p.id.get().copied().unwrap()).collect();

// One batched SELECT + group-by-extractor → HashMap<i64, Vec<C>>
let by_post = prefetch_soft::<Comment, _>(
    &pool,
    &parent_pks,
    "post_id",            // soft-FK column on Comment
    |c| c.post_id,        // extractor: how to read the value off &Comment
).await?;

for post in &posts {
    let pk = post.id.get().copied().unwrap();
    let comments = by_post.get(&pk).map(Vec::as_slice).unwrap_or(&[]);
    // ...
}

Generic-FK prefetch — for (content_type_id, object_pk) pointers that vary their target type per row. Single-target-type variant — caller picks the concrete T: Model to hydrate; pairs whose content_type_id doesn't match are filtered out:

let pairs: Vec<(i64, i64)> = audit_rows.iter()
    .map(|a| (a.target.content_type_id, a.target.object_pk))
    .collect();

let posts: HashMap<(i64, i64), Post> =
    prefetch_generic::<Post>(&pool, &pairs).await?;

for row in &audit_rows {
    if let Some(post) = posts.get(&(row.target.content_type_id, row.target.object_pk)) {
        println!("{}{}", row.id.get().copied().unwrap(), post.title);
    }
}

Both prefetch helpers short-circuit on empty input (no DB round trip) and use a single batched SELECT for the actual fetch.

What this unblocks:

  • Permissions (Option G, v0.16.0)permission.content_type_id is a real FK to rustango_content_types.id instead of a hard-coded app.action_model string that breaks when two apps register the same model name.
  • Audit history admin panelsUser.history.all()-style queries become composite-FK joins instead of raw SQL.
  • Comments / tags / generic FK — one Comment model points at any Post / Photo / Article via (content_type_id, object_pk), queried + admin-rendered in one shape.
  • Activity stream / "recently changed" feeds — the target of each entry hydrates in one batched prefetch_generic call per target type, no N+1.

Deferred (follow-up slice):

  • Boxed-trait dynamic decoder registry → prefetch_generic_dyn for mixed-target hydration in one query.
  • Admin renderer for GenericForeignKey columns (clickable target links in list/detail).
  • composite_relations snapshot/diff support in make_migrations.

Migrations

cargo run -- makemigrations              # diff inventory ↔ snapshot, emit JSON
cargo run -- migrate                     # apply pending
cargo run -- migrate --dry-run           # print SQL only
cargo run -- migrate <target>            # forward or back to specific name
cargo run -- downgrade 2                 # step back 2 migrations
cargo run -- showmigrations              # status

Migration files are JSON in migrations/, lex-sorted by name. They:

  • Embed the full schema snapshot — any one file is a self-contained starting state
  • Include both schema ops AND data ops in the forward list, in any order
  • Are invertible when each op carries reverse_sql (or has a natural inverse)
  • Run atomically per file by default — partial progress recoverable

Auto-detected schema changes

Change Op generated Notes
New struct CreateTable + deferred FK constraints
Removed struct DropTable CASCADE
New field AddColumn rejects NOT NULL without default
Removed field DropColumn
Type changed AlterColumnType with USING ::pg_type cast
Nullable flipped AlterColumnNullable
Default changed AlterColumnDefault
max_length changed AlterColumnMaxLength VARCHAR(N) ↔ TEXT
unique toggled AlterColumnUnique
New #[rustango(index)] / composite index CreateIndex unique flag respected
New #[rustango(check(...))] AddCheckConstraint
New M2M relation CreateM2MTable composite PK + 2 FKs ON DELETE CASCADE
Renames NOT auto-detected use cargo run -- makemigrations --empty and edit JSON

Hand-authored data migrations

# Quick path — one-liner
cargo run -- add-data-op \
    --sql "UPDATE posts SET slug = lower(title)" \
    --reverse-sql "UPDATE posts SET slug = NULL" \
    --name backfill_post_slugs

# Or append to an existing migration
cargo run -- add-data-op --to 0003_add_slug --sql "UPDATE posts SET slug = id::text"

# Or scaffold an empty file and edit manually
cargo run -- makemigrations --empty seed_initial_categories

Auto-admin

Mount once and every #[derive(Model)] is fully editable:

let app = rustango::admin::Builder::new(pool.clone())
    .title("My App Admin")
    .show_only(["post", "author", "tag"])
    .read_only(["audit_log"])
    .build();

Lives at /__admin/. Per-model customization via #[rustango(admin(...))]:

Knob Effect
list_display = "f1, f2, f3" Columns on list view (FKs render display-name)
search_fields = "f1, f2" ?q=... search box
list_filter = "fk_field, status" Right-rail facet filters with counts
ordering = "field, -other" Default sort (- prefix = DESC)
list_per_page = 50 Pagination size
readonly_fields = "created_at" Shown but not editable
fieldsets = "Group A: f1, f2 | Group B: f3" Form layout
actions = "delete_selected, my_action" Bulk actions

Bulk actions

use rustango::bulk_actions::{BulkActionRegistry, BulkDeleteAction, BulkSoftDeleteAction};
use std::sync::Arc;

let registry = BulkActionRegistry::new()
    .register(Arc::new(BulkDeleteAction))
    .register(Arc::new(BulkSoftDeleteAction { column: "deleted_at" }));

// In a handler:
let result = registry.run("delete_selected", "posts", &[1, 2, 3], &pool).await?;
println!("affected {} rows", result.affected);

APIs (ViewSet + Serializer + JWT)

#[derive(ViewSet)] — full CRUD in 5 lines

use rustango::ViewSet;

#[derive(ViewSet)]
#[viewset(
    model         = Post,
    fields        = "id, title, body, author_id, published_at",
    filter_fields = "author_id, status",
    search_fields = "title, body",
    ordering      = "-published_at",
    page_size     = 20,
    permissions(
        list     = "post.view",
        retrieve = "post.view",
        create   = "post.add",
        update   = "post.change",
        destroy  = "post.delete",
    ),
)]
pub struct PostViewSet;

// Mount:
let app = Router::new()
    .merge(PostViewSet::router("/api/posts", pool.clone()));

Endpoints

Method Path Action
GET /api/posts List (page-number or cursor pagination)
POST /api/posts Create
GET /api/posts/{pk} Retrieve
PUT /api/posts/{pk} Update
PATCH /api/posts/{pk} Partial update
DELETE /api/posts/{pk} Delete (soft when model carries #[rustango(soft_delete)])

Query params (list endpoint)

?page=2&page_size=50                            # page-number pagination
?cursor=eyJpZCI6MTAwfQ&page_size=50             # cursor pagination (opt-in)
?search=rust&ordering=-published_at             # search + sort
?author_id=42                                    # exact filter
?published_at__gte=2026-01-01                    # Django-style lookup operators
?title__icontains=draft                          # gt, gte, lt, lte, ne, in, not_in,
                                                 # contains, icontains, startswith,
                                                 # istartswith, endswith, iendswith, isnull

Serializers

use rustango::Serializer;
use rustango::serializer::ModelSerializer;

#[derive(Serializer, serde::Deserialize, Default)]
#[serializer(model = Post)]
pub struct PostSerializer {
    pub id: i64,
    pub title: String,

    #[serializer(read_only)]
    pub created_at: chrono::DateTime<chrono::Utc>,

    #[serializer(write_only)]
    pub draft_password: String,

    #[serializer(source = "body")]                // read from model.body
    pub content: String,

    #[serializer(skip)]                            // user sets manually
    pub tag_ids: Vec<i64>,
}

// Use:
let s = PostSerializer::from_model(&post);
let json = s.to_value();
let array = PostSerializer::many_to_value(&posts);

JWT lifecycle

use rustango::tenancy::jwt_lifecycle::{JwtLifecycle, JwtTokenPair};
use serde_json::json;

let jwt = JwtLifecycle::new(secret).with_access_ttl(900).with_refresh_ttl(7 * 86400);

// Login: embed roles + scope in the token
let pair = jwt.issue_pair_with(user_id, json!({
    "roles":  ["admin", "editor"],
    "tenant": "acme",
    "scope":  "read:posts write:posts",
}).as_object().unwrap().clone())?;

// Authenticated request — no DB lookup needed:
let claims = jwt.verify_access(&access_token).ok_or(StatusCode::UNAUTHORIZED)?;
let roles: Vec<String> = claims.get_custom("roles").unwrap();

// Refresh — preserves custom claims:
let new_pair = jwt.refresh(&refresh_token).ok_or(StatusCode::UNAUTHORIZED)?;

// Refresh with re-evaluated permissions:
let downgraded = jwt.refresh_with(&refresh_token, json!({"roles": ["viewer"]})...)?;

// Logout:
jwt.revoke(&access_token);
jwt.revoke(&refresh_token);

Reserved-claim defense: sub, exp, jti, typ cannot appear in custom (returns JwtIssueError::ReservedClaim).


Forms

use rustango::forms::{Form, FormErrors, ModelForm};
use rustango::Form as DeriveForm;

// Typed form via derive
#[derive(DeriveForm)]
pub struct ContactForm {
    #[form(min_length = 1, max_length = 200)]
    pub name: String,
    #[form(required = false)]
    pub message: Option<String>,
}

// In a handler:
match ContactForm::parse(&form_data) {
    Ok(form) => { /* form.name, form.message */ }
    Err(errors) => { /* errors.fields() per-field map */ }
}

// Or schema-driven for any model
let form = ModelForm::new(Post::SCHEMA, form_data);
match form.save(&pool).await {
    Ok(pk) => redirect_to_detail(pk),
    Err(ModelFormError::Validation(errors)) => render_with_errors(errors),
    Err(ModelFormError::Database(e)) => server_error(e),
}

Multi-tenancy

use rustango::server::Builder;

#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    Builder::from_env().await?
        .admin_title("My SaaS Admin")
        .migrate(".")
        .await?
        .api(my_app::urls::router())
        .seed_with(seed)
        .await?
        .serve("0.0.0.0:8080")
        .await
}
Env var Default Purpose
DATABASE_URL Registry Postgres (orgs, operators, users)
RUSTANGO_APEX_DOMAIN localhost Subdomain root → <slug>.<apex>
RUSTANGO_BIND 0.0.0.0:8080 Bind address
RUSTANGO_SESSION_SECRET random (warns) Base64-encoded 32-byte HMAC key

Generate a secret: openssl rand -base64 32.

Tenant resolver chain

Auto-resolves Tenant from request via, in order:

  1. Subdomain (acme.myapp.com)
  2. URL path prefix (/t/acme/...)
  3. Custom header (X-Tenant-Slug: acme)
  4. Port (rare; for testing)

Storage modes

  • Schema mode — one Postgres schema per tenant, single database
  • Database mode — full database per tenant; TenantPools lazily opens connections

Programmatic provisioning

use rustango::tenancy::manage::api::*;

let org = create_tenant_if_missing(&pools, &registry_url, "migrations", "acme",
    CreateTenantOpts {
        mode: StorageMode::Schema,
        display_name: Some("ACME Corp".into()),
        ..Default::default()
    },
).await?;

create_operator_if_missing(&pools, "admin", "letmein").await?;
create_user_if_missing(&pools, "acme", "alice", "hunter2", true).await?;

Authentication & permissions

Auth backends (pluggable)

use rustango::tenancy::auth_backends::{ModelBackend, ApiKeyBackend, JwtBackend};
use rustango::tenancy::middleware::{RouterAuthExt, CurrentUser};
use std::sync::Arc;

let backends = vec![
    Arc::new(ModelBackend) as _,                  // username + password (Basic Auth)
    Arc::new(ApiKeyBackend) as _,                  // Bearer <prefix>.<secret>
    Arc::new(JwtBackend::new(secret)) as _,        // Bearer <jwt>
];

let app = Router::new()
    .route("/me", get(profile))
    .require_auth(backends.clone(), pool.clone())  // 401 if no backend authenticates
    .route("/posts/new", post(create_post))
    .require_perm("post.add", pool.clone())        // gate by codename
    .require_auth(backends, pool);

async fn profile(CurrentUser(u): CurrentUser) -> impl IntoResponse {
    match u {
        Some(user) => format!("hello {}", user.username).into_response(),
        None => StatusCode::UNAUTHORIZED.into_response(),
    }
}

Typed permission helpers (v0.16.0)

Permissions today use {table}.{action} string codenames ("post.change", "comment.delete", …). The rustango::permissions facade adds typed convenience over the existing engine so callers reach for permissions by their T: Model type instead of hand-typing the codename:

use rustango::permissions;

// Old (still supported):
rustango::tenancy::permissions::has_perm(uid, "post.change", &pool).await?;

// New, typed:
permissions::has_perm_for_model::<Post>(uid, "change", &pool).await?;

// Bulk helpers exist for grant/revoke/set_user_perm/clear_user_perm too:
permissions::grant_role_perm_for_model::<Post>(editor_role, "change", &pool).await?;
permissions::set_user_perm_for_model::<Post>(uid, "delete", false, &pool).await?;

// Build the four standard codenames for a model:
let [add, change, delete, view] = permissions::model_codenames_for::<Post>();

The full underlying engine (Role, RolePermission, UserRole, UserPermission, has_perm / has_any_perm / has_all_perms, assign_role / grant_role_perm / auto_create_permissions) lives at [rustango::tenancy::permissions] — re-exported from the top-level rustango::permissions for the conceptually-cleaner path. Requires the tenancy feature (the underlying tables live in the tenancy bootstrap migration).

TOTP / 2FA

use rustango::totp::{TotpSecret, otpauth_url, generate, verify};

// Enrollment:
let secret = TotpSecret::generate();
user.totp_secret = secret.to_base32();
let qr_url = otpauth_url("MyApp", &user.email, &secret);   // encode as QR

// Verification on login:
if !verify(&secret, &user_supplied_code, 30, 6, 1) {
    return Err("bad TOTP code");
}

API keys

use rustango::api_keys::{generate_key, verify_key, split_token};

// Issue:
let (full_token, prefix, hash) = generate_key()?;
// Show full_token to user once. Store prefix + hash in your DB.

// Verify on request:
let (prefix, secret) = split_token(&inbound).ok_or(BadRequest)?;
let row = db_lookup_by_prefix(prefix).await?;
if verify_key(secret, &row.hash)? { /* ok */ }

Signed URLs (magic links / file downloads)

use rustango::signed_url::{sign, verify};
use std::time::Duration;

// Issue:
let url = sign(
    "https://app.example.com/login?email=alice@x.com",
    secret,
    Some(Duration::from_secs(3600)),
);

// Verify on callback:
match verify(&incoming_url, secret) {
    Ok(()) => { /* identity confirmed */ }
    Err(e) => { /* expired or tampered */ }
}

Security middleware

All optional, all chainable on any axum Router.

use rustango::security_headers::{SecurityHeadersLayer, SecurityHeadersRouterExt, CspBuilder};
use rustango::cors::{CorsLayer, CorsRouterExt};
use rustango::rate_limit::{RateLimitLayer, RateLimitRouterExt};
use rustango::ip_filter::{IpFilterLayer, IpFilterRouterExt};
use rustango::request_id::{RequestIdLayer, RequestIdRouterExt};
use rustango::access_log::{AccessLogLayer, AccessLogRouterExt};
use rustango::etag::{EtagLayer, EtagRouterExt};
use std::time::Duration;

let app = Router::new()
    .route("/api/posts", get(list_posts).post(create_post))
    .security_headers(                                          // HSTS + XFO + CSP + Permissions-Policy
        SecurityHeadersLayer::strict()
            .csp(CspBuilder::strict_starter().build()),
    )
    .cors(                                                      // CORS allowlist
        CorsLayer::new()
            .allow_origins(vec!["https://app.example.com"])
            .allow_methods(vec!["GET", "POST", "PUT", "DELETE"])
            .allow_credentials(true),
    )
    .rate_limit(RateLimitLayer::per_ip(60, Duration::from_secs(60)))   // 60 req/min/IP
    .ip_filter(                                                 // optional allowlist
        IpFilterLayer::block(vec!["203.0.113.42"]).unwrap(),
    )
    .request_id(RequestIdLayer::default())                      // X-Request-Id for log correlation
    .access_log(AccessLogLayer::default())                      // tracing::info per request
    .etag(EtagLayer::default());                                // 304 Not Modified

Security headers presets

Preset When to use
SecurityHeadersLayer::strict() Production: HSTS preload + XFO=DENY + nosniff + Referrer-Policy=no-referrer
SecurityHeadersLayer::relaxed() Embeddable apps: SAMEORIGIN + 1y HSTS
SecurityHeadersLayer::dev() Local: nosniff only (no HSTS lockout)
SecurityHeadersLayer::empty() Build up from scratch

CORS presets

CorsLayer::permissive()                          // dev: any origin, common methods
CorsLayer::new().allow_origins(vec!["..."])      // prod: explicit allowlist

Caching

use rustango::cache::{Cache, BoxedCache, InMemoryCache, get_json, set_json, get_or_set};
use std::sync::Arc;
use std::time::Duration;

// Build a shared cache
let cache: BoxedCache = Arc::new(
    InMemoryCache::with_default_ttl(Duration::from_secs(300))
);

// Raw strings
cache.set("greeting", "hello", Some(Duration::from_secs(60))).await?;
let val: Option<String> = cache.get("greeting").await?;

// Typed JSON helpers
set_json(&*cache, "user:1", &user, None).await?;
let user: Option<User> = get_json(&*cache, "user:1").await?;

// Fetch-or-compute pattern
let posts: Vec<Post> = get_or_set(
    &*cache,
    "posts:recent",
    || async { Post::objects().fetch(&pool).await.unwrap() },
    Some(Duration::from_secs(60)),
).await?;

Redis backend (cache-redis feature)

use rustango::cache::redis_backend::RedisCache;

let cache: BoxedCache = Arc::new(
    RedisCache::new("redis://127.0.0.1/").await?
);

Email + storage + scheduling

Email

use rustango::email::{Mailer, Email, ConsoleMailer, InMemoryMailer, NullMailer};

let mailer: Arc<dyn Mailer> = Arc::new(ConsoleMailer);   // dev: prints to stdout

let email = Email::new()
    .to("user@example.com")
    .from("noreply@app.example.com")
    .reply_to("support@app.example.com")
    .subject("Welcome")
    .body("Plain text version")
    .html_body("<h1>Welcome</h1>");
mailer.send(&email).await?;

File storage

The framework ships three layers, each useful on its own:

  1. Storage trait + backends — write/read/delete/url + presign.
  2. StorageRegistry — named "disks" (Laravel-style) with optional CDN prefixes. Pick the right backend per call site by name.
  3. Media model + MediaManager — first-class Postgres-backed file references with direct browser uploads, soft delete, and orphan sweeps.

Storage backends

use rustango::storage::{Storage, BoxedStorage, LocalStorage};
use rustango::storage::s3::{S3Storage, S3Config};
use std::sync::Arc;

// Local disk:
let local: BoxedStorage = Arc::new(
    LocalStorage::new("./uploads".into())
        .with_base_url("https://cdn.example.com/uploads")
);
local.save("avatars/alice.png", &png_bytes).await?;

// AWS S3 (or Cloudflare R2 / Backblaze B2 / MinIO — same struct):
let s3: BoxedStorage = Arc::new(S3Storage::new(S3Config {
    bucket: "my-bucket".into(),
    region: "us-east-1".into(),
    endpoint: None,                    // Some("https://...") for R2/B2/MinIO
    access_key_id: env::var("AWS_ACCESS_KEY_ID")?,
    secret_access_key: env::var("AWS_SECRET_ACCESS_KEY")?,
    path_style: false,                 // true for R2/MinIO
}));

// Trait methods are identical across backends — swap in config.
s3.save("avatars/alice.png", &png_bytes).await?;
let bytes = s3.load("avatars/alice.png").await?;
let public_url = s3.url("avatars/alice.png");

The S3 backend is hand-rolled SigV4 over reqwest — no aws-sdk-s3 dependency. Behind the storage-s3 feature flag (default-on).

Presigned URLs (private files + direct browser uploads)

use std::time::Duration;

// Time-limited GET: paste into <img src=...> or <a href=...>
let download_url = s3
    .presigned_get_url("invoices/2026.pdf", Duration::from_secs(3600))
    .await
    .expect("S3 backend signs");

// Time-limited PUT: browser uploads directly, server never proxies the body.
// Content-Type binding — browser MUST send a matching header (S3 enforces).
let upload_url = s3
    .presigned_put_url("uploads/x.png", Duration::from_secs(300), Some("image/png"))
    .await
    .unwrap();

LocalStorage and InMemoryStorage return None from these methods (they can't sign). S3Storage (and any S3-compatible API) does. AWS caps presign expiry at 7 days; we clamp.

StorageRegistry — named disks

use rustango::storage::StorageRegistry;

let registry = StorageRegistry::new()
    .set("avatars", Arc::new(s3))
    .cdn("avatars", "https://cdn.example.com/avatars")
    .set("docs",    Arc::new(docs_s3))
    .set("cache",   Arc::new(local))
    .with_default("avatars");

let s = registry.disk("avatars").unwrap();
let url = registry.cdn_url("avatars", "alice.png");
//        → "https://cdn.example.com/avatars/alice.png"

let internal = registry.origin_url("avatars", "alice.png");
//        → bypasses CDN — for internal admin tools

First-class Media model

Media is a Postgres-backed row. User models reference it via a normal integer FK (Option<ForeignKey<Media>>) — all metadata (disk, key, MIME, size, filename, free-form JSONB) lives on the Media row, so deletes are atomic and one file can be referenced by N parents without duplication.

use rustango::media::{Media, MediaManager, SaveOpts, UploadIntent};

// Once at startup:
Media::ensure_table(&pool).await?;
let manager = MediaManager::new(pool.clone(), registry);

// Server-side save: writes to S3 + inserts a Media row in one call.
let m = manager.save_bytes(SaveOpts {
    disk: "avatars".into(),
    key_prefix: "users/".into(),
    bytes: png_bytes.clone(),
    mime: "image/png".into(),
    original_filename: "alice.png".into(),
    uploaded_by_id: Some(user.id),
    metadata: serde_json::json!({"alt": "alice headshot"}),
}).await?;

// CDN-aware URL (falls back to backend URL when no CDN configured):
let url = manager.url(&m).expect("url");

// Time-limited download link for private files:
let dl = manager.presigned_get(&m, Duration::from_secs(3600)).await;

Direct browser uploads (no proxying through your server)

Two-step flow: server issues a presigned PUT URL, browser uploads to S3 directly, server confirms. Big files don't tie up handler bandwidth; you keep server-side gating on size/MIME via the pre-creation step.

// 1. Server: issue a presigned upload ticket.
let ticket = manager.begin_upload(UploadIntent {
    disk: "avatars".into(),
    key_prefix: "uploads/".into(),
    mime: "image/png".into(),
    original_filename: "selfie.png".into(),
    size_bytes: 12_345,
    uploaded_by_id: Some(user.id),
    ttl: Duration::from_secs(300),
}).await?;
// ticket.media_id    -> the Pending Media row id
// ticket.upload_url  -> hand to the browser
// ticket.expires_at  -> show client a deadline

// 2. Browser:
//    fetch(ticket.upload_url, {
//        method: 'PUT',
//        headers: { 'Content-Type': 'image/png' },
//        body: file
//    })

// 3. Server: confirm the object landed; flips Pending → Ready
//    (or → Failed if the browser abandoned).
let m = manager.finalize_upload(ticket.media_id).await?;
assert!(m.is_ready());

Lifecycle + cleanup

// Soft delete: marks deleted_at = NOW() but preserves the storage object.
manager.delete(&m).await?;

// Hard purge: removes both the row AND the storage object. Typical
// "clean up after soft delete grace period" pattern.
manager.purge(&m).await?;

// Background sweeps — wire to rustango::scheduler:
manager.purge_orphans(Duration::from_secs(7 * 86400)).await?;  // 7-day grace
manager.purge_pending(Duration::from_secs(86400)).await?;      // abandoned uploads

The MediaStatus enum (Pending / Ready / Failed) is stored as TEXT so admins can filter / order without bespoke type handling. Soft-deleted rows are excluded from manager.get(...) by default; manager.get_including_deleted(...) brings them back for restore flows.

Collections (folders) + tags

MediaCollection is a hierarchical "where the file lives" folder — one Media row belongs to at most one collection; collections nest via parent_id. MediaTag is a flat M2M label — one Media has any number of tags. Both are first-class Postgres tables, so the auto-admin lists/filters/searches them with no extra wiring.

use rustango::media::ensure_all_tables;

// Bootstrap rustango_media + rustango_media_collections +
// rustango_media_tags + rustango_media_tag_links in one call.
ensure_all_tables(&pool).await?;

// Folders (hierarchical).
let products = manager.create_collection("Products", "products", None, "").await?;
let cid = match products.id { rustango::sql::Auto::Set(v) => v, _ => unreachable!() };
let launch = manager.create_collection("2026 Launch", "2026-launch", Some(cid), "").await?;

// Drop a file into a folder at save-time.
let m = manager.save_bytes(SaveOpts {
    disk: "avatars".into(),
    key_prefix: "products".into(),
    bytes: png,
    mime: "image/png".into(),
    original_filename: "hero.png".into(),
    uploaded_by_id: Some(user.id),
    collection_id: launch.id.into(),     // folder
    metadata: serde_json::json!({}),
}).await?;

// Move it later.
let mid = match m.id { rustango::sql::Auto::Set(v) => v, _ => unreachable!() };
manager.move_to_collection(mid, Some(cid)).await?;

// Walk the folder path.
let path = manager.collection_path(cid).await?;          // "products"

// List media in a folder, optionally recursive.
let in_folder = manager.list_in_collection(cid, true).await?;

// Tags (M2M, free-form labels).
manager.tag(mid, &["featured", "approved", "homepage-hero"]).await?;
manager.untag(mid, "homepage-hero").await?;

// Replace the entire tag set:
manager.set_tags(mid, &["featured", "draft"]).await?;

// Find media by tag, paginated.
let featured = manager.list_with_tag("featured", 50, 0).await?;

// Top tags by usage:
let popular = manager.popular_tags(10).await?;           // Vec<(MediaTag, i64)>

Deleting a collection orphans its Media (sets collection_id = NULL) — the rows + storage objects survive, just lose their folder. Deleting a tag cascades the junction rows away (tags are cheap to recreate).

REST router

The media_router exposes the manager surface as JSON endpoints — drop it under any prefix you like:

use rustango::media::router::media_router;

let app = axum::Router::new()
    .nest("/media", media_router(manager));
Method Path Purpose
POST /uploads/begin Issue a presigned PUT ticket for browser upload
POST /uploads/{id}/finalize Confirm storage object landed → flips pending → ready
GET /media/{id} Single Media row with url, presigned_url, tags
DELETE /media/{id} Soft-delete (storage preserved)
POST /media/{id}/move Move to another collection: {collection_id?}
POST /media/{id}/tags Replace tag set: {slugs: [...]}
DELETE /media/{id}/tags/{slug} Remove a single tag
POST /collections Create folder: {name, slug, parent_id?, description?}
GET /collections List all (non-deleted) folders
GET /collections/{id} Single folder
DELETE /collections/{id} Soft-delete (Media inside orphaned, NOT deleted)
GET /collections/{id}/contents Media in folder. ?recursive=1 includes sub-folders
POST /tags Create / upsert tag: {slug}
GET /tags All tags
GET /tags/popular Top tags by use count. ?limit=N
GET /tags/{slug}/media Media carrying the tag. ?limit=N&offset=N

MediaError implements IntoResponse so unknown disks return 400, storage transport errors 502, and "not found" errors 404.

Behind the media feature flag (default-on, implies storage + postgres); media_router additionally needs the admin feature for axum.

Scheduled tasks

use rustango::scheduler::Scheduler;
use std::time::Duration;

let s = Scheduler::new();

s.every("cleanup_sessions", Duration::from_secs(300), || async {
    cleanup_expired().await.ok();
});

s.every("rotate_logs", Duration::from_secs(86_400), || async {
    rotate().await.ok();
});

let handle = s.start();   // each task runs in its own tokio task with panic isolation
// ... app runs ...
handle.shutdown().await;

Signals

use rustango::signals::{connect_post_save, send_post_save, PostSaveContext};

// Register at startup:
connect_post_save::<Post, _, _>(|post, ctx| async move {
    if ctx.created {
        tracing::info!("New post #{}", post.id.get().copied().unwrap_or(0));
    }
});

// Fire after save (until macro auto-fires it for you):
post.save_on(&pool).await?;
send_post_save(&post, PostSaveContext { created: true }).await;

Available: connect_pre_save, connect_post_save, connect_pre_delete, connect_post_delete. Disconnect via the returned ReceiverId.


i18n

use rustango::i18n::{Translator, Locale, negotiate_language};

// Load catalogs from disk
let t = Translator::from_directory("./locales".as_ref(), Locale::new("en"))?;

// Or build manually
let t = Translator::new(Locale::new("en"))
    .add_locale(Locale::new("en"), HashMap::from([
        ("welcome".into(), "Welcome, {name}!".into()),
    ]))
    .add_locale(Locale::new("fr"), HashMap::from([
        ("welcome".into(), "Bienvenue, {name} !".into()),
    ]));

// Translate
let s = t.translate("fr-FR", "welcome", &[("name", "Alice")]);
// → "Bienvenue, Alice !"  (fr-FR falls back to fr)

// Pick from Accept-Language
let lang = negotiate_language(
    "fr-FR,fr;q=0.9,en;q=0.8",
    &t.locales(),
);

Testing

Test client

use rustango::test_client::TestClient;

#[tokio::test]
async fn create_post_returns_201() {
    let app = build_app().await;
    let client = TestClient::new(app);

    let response = client.post("/api/posts")
        .header("authorization", "Bearer eyJ...")
        .json(&serde_json::json!({"title": "Hi"}))
        .send().await;

    assert_eq!(response.status, 201);
    let post: serde_json::Value = response.json();
    assert_eq!(post["title"], "Hi");
}

Fixtures

use rustango::fixtures::{Fixture, load_all};

let users = Fixture::new("users").from_file("fixtures/users.json")?;
let posts = Fixture::new("posts").from_file("fixtures/posts.json")?;

load_all(&[
    ("rustango_users", &users),         // load parents first
    ("posts", &posts),
], &pool).await?;

fixtures/users.json:

[
    {"username": "alice", "email": "a@x.com"},
    {"username": "bob",   "email": "b@x.com"}
]

Feature flags

The default features cover everything most apps need. Trim them when shipping a slim binary:

# Default — everything except tenancy + cache-redis
rustango = "0.23"

# Multi-tenant
rustango = { version = "0.23", features = ["tenancy"] }

# With Redis cache
rustango = { version = "0.23", features = ["cache-redis"] }

# Bare ORM only (no admin, no forms, no email, no storage)
rustango = { version = "0.23", default-features = false, features = ["postgres"] }
Feature What it adds On by default?
postgres sqlx + Postgres driver yes
admin rustango::admin HTTP layer (axum, Tera) yes
config layered TOML config + env overrides yes
forms rustango::forms parsers + ModelForm yes
serializer #[derive(Serializer)] yes
cache Cache trait + InMemoryCache + NullCache yes
cache-redis + RedisCache no
signals pre/post save/delete dispatcher yes
email Mailer trait + console/in-memory/null yes
storage Storage trait + Local/InMemory yes
scheduler in-process cron-shape scheduler yes
secrets Secrets trait + Env/InMemory yes
totp RFC 6238 2FA yes
webhook inbound HMAC signature verification yes
api_keys {prefix}.{secret} argon2 keys yes
passwords argon2 hash + strength check yes
signed_url HMAC-SHA256 signed URLs yes
tenancy multi-tenancy + operator console + permissions no
csrf CSRF middleware (depends on forms) implied by admin

Production checklist

Run before deploy:

cargo run -- check --deploy

Audits:

  • ✅ DEBUG-style env (RUSTANGO_ENV is prod or production)
  • SECRET_KEY set and ≥ 32 bytes
  • DATABASE_URL set
  • ✅ Pending migrations applied
  • ✅ Models registered in inventory

Then verify your stack has:

Layer Required Tool in rustango
HTTPS termination yes (reverse proxy — nginx / cloudflare / aws ALB)
Security headers yes SecurityHeadersLayer::strict()
Rate limiting yes RateLimitLayer::per_ip(...)
Access logging yes AccessLogLayer::default() (PII-redacted by default)
Health endpoints yes health::health_router(pool)/health, /ready
Request IDs recommended RequestIdLayer::default()
CORS allowlist if you have a JS frontend CorsLayer::new().allow_origins(...)
ETag caching optional EtagLayer::default()
Backups yes external — pg_dump

Comparison

rustango Django Laravel Rocket Cot
ORM
Auto-migrations
Auto-admin ⚠️ Filament
Multi-tenancy ⚠️ ext ⚠️ ext
JWT lifecycle (refresh + blacklist + custom claims) ⚠️ ext ⚠️ Sanctum/Passport
TOTP / 2FA ⚠️ ext ✅ Fortify
Signals ✅ Events
Cache backends ⚠️ optional
Email backends
File storage ⚠️ ext ✅ Flysystem
Scheduled tasks ⚠️ Celery beat
Security headers ⚠️ middleware ✅ Shield
Test client ✅ Client
Project scaffolder cargo rustango new startproject ✅ Laravel installer cot new
File generators make:* ⚠️ ext ✅ artisan

✅ shipped · ⚠️ partial / via extension · ❌ not shipped


Documentation


License

MIT OR Apache-2.0