Rustango docs
← Guides

manage CLI reference

This is Rustango's command-line tool, like Laravel's artisan or Rails' rails command. In a project scaffolded via cargo rustango new, one binary runs every command ("verb"):

cargo run                          # runserver (no args = boot the HTTP server)
cargo run -- migrate               # any other verb
cargo run -- --help                # full subcommand list

One binary runs every manage verb — server, migrations, scaffolders, database utilities, and system commands

Source: rustango::manage (Cli, the verb dispatcher) — behind the manage feature (on by default).

Runnable version: every verb here runs in a scaffolded project; the getting_started_blog example is driven by cargo run -- migrate and friends.

New to a term here? scaffold, migration, tenant — see the glossary.

The command router lives in rustango::manage::Cli; your src/main.rs wires it up like this:

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

Multi-tenant projects add .tenancy() to the chain. That switches the router to rustango::tenancy::manage and unlocks the multi-tenant commands.

Older shape — projects scaffolded by manage startapp --with-manage-bin (or pre-v0.16 ones) still ship src/bin/manage.rs. Those use cargo run --bin manage -- <verb>. Both forms accept the same verbs.

Every command prints to stdout and exits with a non-zero code on validation or I/O errors. Run cargo run -- --help (or <verb> --help) for inline usage.


Table of contents


Every verb

The sections below explain the commonly used verbs in depth. This table is the complete list, taken from the two dispatchers (migrate/manage.rs and tenancy/manage/mod.rs) rather than from the prose — so a verb missing from the guide is still findable here. Run <verb> --help for its flags; the help text is authoritative and this page is not.

Verbs marked T need the tenancy feature and are reached through Cli::tenancy().

Migrations and schema

VerbWhat it does
makemigrations [name] / --empty <name>Generate a migration from the model diff
migrate [target] / --dry-run / --squashApply pending migrations
downgrade [N]Roll back the last N migrations
showmigrations / statusList migrations and their applied state
sqlmigrate <name>Print the SQL a migration would run, without running it
forget-pending <name>Delete an un-applied migration JSON
add-data-op --sql <SQL> [--reverse-sql <SQL>]Append a hand-written data operation
inspectdb [--schema <s>] [--table <t>]Read a live schema and emit #[derive(Model)] source

Data

VerbWhat it does
dumpdataExport rows as JSON fixtures
loaddata <fixture.json> [--fail-fast]Load JSON fixtures back in. A failed or partial load is not rolled back
flush [--yes] [--app <label>] [--model <name>]Wipe every model table; the flags limit the set. Postgres uses TRUNCATE … RESTART IDENTITY CASCADE, which also clears referencing tables outside the filter; MySQL / SQLite delete rows and keep id counters
prune [--model <name>] [--except <name>] [--pretend]Streaming bulk delete; --pretend reports without deleting
db:dump / db:restore / db:infoNative dump / restore / inspect
dbshellExec the native client (psql / mysql / sqlite3). Needs only DATABASE_URL, not a working pool — it is handled before the pool is built, so it works when sqlx cannot connect

Scaffolders and generators

VerbWhat it does
startapp <name>Scaffold an app module
make:viewset / make:serializer / make:formGenerate a ViewSet, Serializer or Form
make:job / make:scheduled / make:worker / make:middleware / make:notification / make:testGenerate a queue job, a timer task, a worker binary, middleware, notification or test
make:api_routes <app> [--tenant]Generate an app's API route module

Cache, sessions and mail

VerbWhat it does
createcachetable / create-cache-table [--table <name>]Create the cache table (and the session table when sessions go to the DB)
clear-cache [--table <name>] / clearsessionsEmpty it; returns the number of rows deleted
sendtestemail --to <addr>Send a fixed test email through the configured backend

Introspection

VerbWhat it does
showmodels [--format plain|json] [--app <label>]Every registered model, sorted for deterministic output
showurls [--format plain|json]Every named route, sorted
check [--deploy]Health checks; --deploy adds the production audits
create-adminBootstrap an AdminUser row for projects using admin::Builder::with_session_auth. Not tenancy-gated — it takes a plain &Pool and writes rustango_admin_users, creating the table if absent. The only way to get a first admin login on a non-tenancy project
about / version / --versionBuild and version information
docsOpen the documentation

Users and access T

VerbWhat it does
create-superuser / set-superuserCreate a superuser, or promote an existing user
create-user / create-operatorCreate a tenant user or an operator
reset-password / change-passwordTenant-user password recovery
reset-operator-password / change-operator-passwordOperator password recovery
set-operator-activeEnable or disable an operator
create-role / assign-role / revoke-role / list-rolesRoles
grant-perm / revoke-permPermissions by codename
seed-permissions [--slug <s>]Seed the default permission rows
create-api-keyIssue an API key

Tenants T

VerbWhat it does
create-tenant / edit-tenant / list-tenantsProvision, edit, list
drop-tenant / purge-tenantDeactivate (reversible) / destroy (not)
migrate-tenants / migrate-registryApply migrations across tenants, or to the registry
migrate-tenant-storage <slug> --to schema|databaseMove a tenant between storage modes
add-host / remove-host / list-hosts / set-host-enabledHost routing
test-tenant-connectionVerify a tenant's database is reachable
prewarm-poolsOpen tenant pools ahead of first request
run-server / runserverRun the multi-tenant server
init / init-tenancy / wizard / menu / actionsSetup and interactive entry points

Audit T

VerbWhat it does
audit-logRead the audit trail
audit-cleanupTrim it

MCP T

Documented in full in the MCP guide.

VerbWhat it does
create-agent / list-agents / rotate-agent-secretAgents
create-skill / list-skills / grant-skill / revoke-skillSkills
map-skill-permission / unmap-skill-permissionBind a skill to a permission
create-user-key / list-user-keys / revoke-user-keyPer-user MCP credentials
list-runs / show-runRun history

Migrations

makemigrations [name]

Generates a migration file from changes to your models. It compares your registered models against the last saved schema snapshot in migrations/ and writes a new JSON file with whatever changed.

cargo run -- makemigrations                          # auto-name (e.g. 0004_add_slug_to_posts)
cargo run -- makemigrations rename_status_to_state   # custom suffix

Auto-detected changes:

  • CreateTable / DropTable
  • AddColumn / DropColumn
  • AlterColumnType / AlterColumnNullable / AlterColumnDefault / AlterColumnMaxLength
  • AlterColumnUnique
  • CreateIndex / DropIndex
  • AddCheckConstraint / DropCheckConstraint
  • CreateM2MTable / DropM2MTable

NOT auto-detected (rename vs drop+add is ambiguous):

  • RenameTable, RenameColumn — use --empty and edit the JSON.

makemigrations --app <app>

Scopes the migration to a single app. It writes to that app's own <project_root>/<app>/migrations/ directory and only looks at models belonging to that app.

cargo run -- makemigrations --app blog
cargo run -- makemigrations --app blog backfill_slugs

makemigrations --scope <registry|tenant>

Multi-tenant only. Writes a single migration for just the models in one scope — those whose #[rustango(scope = "...")] attribute matches. ("Registry" tables are shared across all tenants; "tenant" tables live per tenant.) Without this flag, a plain makemigrations in a tenancy project automatically splits the changes into TWO files — one for registry models, one for tenant models — so shared framework tables (Org, Operator) don't leak into the per-tenant migrations that migrate-tenants runs.

cargo run -- makemigrations                       # tenancy: writes 0NN_<auto>.json (registry) + 0MM_<auto>.json (tenant) as needed
cargo run -- makemigrations --scope tenant        # explicit single-scope diff
cargo run -- makemigrations --scope registry      # explicit single-scope diff

Why the split matters: before v0.24.2, a plain makemigrations on a tenancy project bundled operations on rustango_operators (a registry table) into a tenant migration. When migrate-tenants ran that file, rustango_operators resolved via search_path to the registry copy and clashed with the constraint already there.

makemigrations --empty <name>

Creates a blank migration (no forward operations) for you to fill in by hand. Use it when you need to write data operations or rename operations the auto-detector can't generate. Edit the resulting JSON yourself.

cargo run -- makemigrations --empty rename_status_to_state
# Then edit migrations/0005_rename_status_to_state.json:
#   "forward": [
#     {"schema": {"RenameColumn": {"table": "posts", "old_column": "status", "new_column": "state"}}}
#   ]

makemigrations --merge

Fixes a migration history that has split into two branches (issue #346). This happens when two people each run makemigrations on their own feature branch, so both new files point at the same parent. After both branches merge, the history has two "leaves" (end points), and the next makemigrations would arbitrarily pick one as its parent.

--merge detects this and writes an empty NNNN_merge.json whose parent points at the last leaf alphabetically, reuniting the history into one chain. Its schema snapshot reflects the combined state, read from the live model registry — both branches' models are compiled in at this point, so the snapshot is accurate.

cargo run -- makemigrations --merge
# wrote migrations/0004_merge.json
#     merge node — empty `forward`, anchors the chain after divergent leaves
  • Already a single chain → prints no merge needed and exits cleanly. Safe to run on a healthy history.
  • Genuinely separate histories (not a branch collision) → errors out instead of inventing a parent.
  • Cannot be combined with --empty, --app, --scope, or a positional name.

migrate

Applies all pending migrations to the database, in order — like Laravel's php artisan migrate. This is the command you run after makemigrations to actually change your schema.

cargo run -- migrate
cargo run -- migrate --dry-run                       # print SQL without writing

Each file runs inside a transaction by default, so a failure rolls the whole file back. Set "atomic": false in the JSON to opt out — you need that for statements like CREATE INDEX CONCURRENTLY that can't run in a transaction.

In tenancy mode (Cli::tenancy()), migrate is scope-aware: it first applies registry migrations to the shared registry database, then applies tenant migrations across every active tenant. For finer control, use migrate-registry / migrate-tenants.

migrate <target>

Migrates to a specific point in the history, forward or backward. Name a migration to move to it; the special target zero undoes everything.

cargo run -- migrate 0003_add_slug      # forward to 0003
cargo run -- migrate 0001_initial       # roll back to 0001 (unapply 0002+)
cargo run -- migrate zero               # unapply EVERY migration

migrate --squash

Collapses every pending (un-applied) migration into one freshly generated diff — the dev-iteration escape hatch for when a stack of half-finished migrations is easier to regenerate than to fix. It refuses to touch anything already applied.

cargo run -- migrate --squash

The regenerated file records the names it collapsed in its replaces list. That matters the moment another database is involved: your colleague's checkout, staging, or CI may already have applied some of the files you just deleted. Without replaces the new file's CREATE TABLE would collide there; with it, the runner reconciles instead (see below).

Squash reconciliation

A squash recreates the end state of the migrations it replaces, so what the runner should do depends entirely on what the target database already contains. It decides automatically:

database statewhat happens
fresh — no history, no tablesthe squash runs for real
every replaced migration is in the ledgerrecorded, predecessors tombstoned, no DDL
tables exist but the ledger has no historyrecorded, no DDL (adopts the existing tables)
only some replaced rows / tables presentrefused, naming what's missing

The partial case is deliberately a hard error: no automatic choice is safe there, so the runner stops and tells you what it found instead of guessing. Resolve it with migrate --fake (below).

Migrations superseded by an applied squash are treated as applied, so you can leave the old files on disk for a release or two — deployments that never ran them still migrate forward correctly.

Ordinary (non-squash) migrations are unaffected: a plain migration whose table already exists still fails loudly, because that is a real conflict rather than a known-equivalent history.

migrate --fake <name>

Stamps a migration as applied without running its SQL — the operator escape hatch when the database is already in the target state but the ledger doesn't know it (a DB set up out-of-band, a dropped ledger table, a partially-succeeded migration, a refused partial squash). Repeat the flag to repair several rows at once.

cargo run -- migrate --fake 0004_add_indexes
cargo run -- migrate --fake 0004_add_indexes --system        # framework's own chain
cargo run -- migrate --fake 0004_add_indexes --all-tenants   # every active tenant

The name is validated against the migration directory first, so a typo can't land a bogus row. Stamping is idempotent.

--system targets the framework's own migration chain (system/migrations/, recorded in __rustango_system_migrations__) rather than your project's. --all-tenants fans the stamp out across every active tenant, reporting each one and continuing past failures — the framework's tables live per tenant, so repairing them is a per-tenant job.

downgrade [N]

Rolls back the last N applied migrations (default 1) — Laravel's migrate:rollback. Each migration must be reversible: schema changes reverse automatically, but data operations need a reverse_sql defined or the rollback fails.

cargo run -- downgrade                  # one step
cargo run -- downgrade 3                # three steps

showmigrations / status

Lists every migration and whether it's been applied. [X] means applied, [ ] means still pending.

cargo run -- showmigrations
cargo run -- status                     # alias

Output:

[X] 0001_initial
[X] 0002_add_status
[ ] 0003_add_slug

Data migrations

add-data-op

Adds a raw-SQL data step to a migration without editing JSON by hand. Reach for this when you need to transform existing rows — backfill a column, clean up data — as part of a migration. The SQL runs as one step of the migration, and the command writes the JSON for you.

# New migration with up + down
cargo run -- add-data-op \
    --sql "UPDATE posts SET slug = lower(title)" \
    --reverse-sql "UPDATE posts SET slug = NULL" \
    --name backfill_post_slugs

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

# Irreversible (no rollback)
cargo run -- add-data-op \
    --sql "DELETE FROM legacy_data" \
    --name purge_legacy
FlagRequiredDescription
--sql <SQL>yesForward SQL to run on migrate
--reverse-sql <SQL>noRollback SQL on unapply; omit for irreversible
--name <name>noNew-migration name suffix; defaults to data_op
--to <migration>noAppend to an existing migration instead of creating one

Leave off --reverse-sql and the step is marked reversible: false — any attempt to roll it back fails immediately.


Project / app scaffolders

cargo rustango new <name> (separate binary)

Creates a brand-new Rustango project — like laravel new. This is a separate tool, so install it first with cargo install cargo-rustango. Pick from three templates:

cargo rustango new myblog                          # default = fullstack (ORM + admin)
cargo rustango new myapi --template api            # JSON-only, no admin
cargo rustango new shop --template tenant          # multi-tenancy

Writes:

<name>/
  Cargo.toml
  .env.example
  .gitignore
  rust-toolchain.toml
  docker-compose.yml
  Dockerfile                                (deploy image — multi-stage, release, non-root)
  Dockerfile.dev                            (cargo-watch image docker-compose.yml builds)
  .dockerignore
  README.md
  config/default.toml                       (shared knobs)
  config/{dev,staging,prod}_settings.toml   (per-tier overrides)
  migrations/                               (your app's migrations)
  system/migrations/                        (framework tables, generated — commit them)
  src/{lib,main,models,views,urls}.rs

The config/ tier is the settings source: default.toml holds what every environment shares and one <env>_settings.toml overrides it, selected at runtime by RUSTANGO_ENV (default dev), so a fresh cargo run works with no TOML edits. Between them they carry [database] pool tuning, [admin], [mcp] and the secure_cookies policy — and they are what check --deploy's settings audit reads. See Scaffolding for the per-tier contents.

Every template ships an empty system/migrations/ folder. The framework's own tables (rustango_orgs, rustango_users, roles/permissions, …) are generated into it from the compiled models on the first cargo run -- migrate — there's no hand-shipped bootstrap JSON. See migrate / migrate-registry.

startapp <name> [flags]

Creates a new app (a feature module) under src/<name>/. Use it to keep models, views, and URLs for one part of your project grouped together.

cargo run -- startapp blog
cargo run -- startapp shop --with-manage-bin             # also writes src/bin/manage.rs
cargo run -- startapp shop --into apps                   # write under src/apps/shop/ instead

Creates:

src/<name>/
  mod.rs
  models.rs
  views.rs
  urls.rs

Safe to re-run — existing files are left alone. One manual step: add pub mod <name>; to src/lib.rs so Rust compiles the new module.


File generators (make:*)

These create starter files for common building blocks — much like Laravel's make:* commands (make:controller, make:model, …). Each generator writes to src/<snake_name>.rs (or tests/<snake_name>.rs for make:test) and:

  • Checks the name is valid (PascalCase, letters/digits/underscore) — these generators emit a type, so the name is a type name. make:test is the exception: it emits a file of test functions, so it takes any Rust identifier and make:test post_smoke works as shown below.
  • Converts it to snake_case for the filename (PostViewSet → post_view_set.rs).
  • Won't overwrite an existing file.
  • Reminds you to add pub mod X; to your lib.rs.

make:viewset <Name> [--model <Model>] [--tenant | --no-tenant] [--crate <path>]

Generates a full REST endpoint for a model — list, create, retrieve, update, delete.

Two templates, chosen for you. The single-pool #[derive(ViewSet)] captures one pool at mount time, which is wrong for a tenancy project, so the generator picks between two shapes. Resolution order: --no-tenant wins, then --tenant, then auto-detection — tenancy in the rustango dep's feature list in Cargo.toml — and otherwise the pool template. When auto-detection picks tenant mode it says so on stdout and names --no-tenant as the override.

cargo run -- make:viewset PostViewSet --model Post
cargo run -- make:viewset PostViewSet --model Post --no-tenant   # force pool shape

Pool template — generated src/post_view_set.rs:

#[derive(ViewSet)]
#[viewset(model = Post, fields = "id, ", filter_fields = "", search_fields = "", page_size = 20)]
pub struct PostViewSet;

Mount with: .merge(PostViewSet::router("/api/posts", pool.clone())).

Tenant template — not a derive. It emits a pub fn router() -> Router<()> built from ViewSet::for_model(Post::SCHEMA).tenant_router("/api/posts"), with the whole builder chain (fields / filter_fields / search_fields / ordering / page_size / permissions_for_model / read_only) stubbed out as commented lines. The connection is resolved per request through the Tenant extractor rather than captured once at mount time, so one router() serves every tenant.

Mount with: .merge(crate::viewsets::post::router()) — not the PostViewSet::router(path, pool) line above.

--crate <path> renames the framework crate in the generated use lines, for projects that import rustango under a different name.

make:serializer <Name> [--model <Model>]

Generates a #[derive(Serializer)] struct — controls how a model is converted to and from JSON.

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

make:form <Name>

Generates a #[derive(Form)] struct for validating and processing form input.

cargo run -- make:form ContactForm

make:job <Name>

Generates a jobs::Job — a payload struct plus the trait impl, with NAME, MAX_ATTEMPTS and async fn run(&self). This is work you enqueue from a handler and a worker executes later.

run receives only the payload: no pool, no tenant, no request context. Carry what the job needs in its fields.

cargo run -- make:job SendReceipt

For work that runs on a timer rather than from a queue, see make:scheduled.

make:scheduled <Name>

Generates a fixed-interval task for scheduler::Scheduler — the shape make:job used to emit before it scaffolded an actual job.

cargo run -- make:scheduled NightlySweep

make:worker <Name>

Generates a standalone worker binary for src/bin/ — a process that drains the job queue and serves no HTTP. Run it beside the web process, or as its own container.

The shape is short and easy to get wrong in a way that only appears in production: a worker that awaits tokio::signal::ctrl_c() handles SIGINT but not SIGTERM, which is what docker stop, Kubernetes and systemd send. The drain then never runs, the container is killed after its grace period, and in-flight jobs are lost with nothing logged. The generated worker awaits shutdown::shutdown_signal(), which takes both.

cargo run -- make:worker JobsWorker

make:notification <Name>

Generates a notification struct that builds an email — like Laravel's make:notification.

cargo run -- make:notification WelcomeEmail

make:middleware <Name>

Generates a middleware function — code that runs before and after each request (auth checks, logging, and so on). "axum" is the web framework Rustango is built on, so the stub matches axum's middleware shape.

cargo run -- make:middleware AuditLog

make:test <Name>

Generates an integration test in tests/ that uses TestClient to make requests against your app.

cargo run -- make:test post_smoke

Database utilities

db:info

Shows which database this build is configured to talk to, without connecting. It prints the framework version, which database drivers (postgres/mysql Cargo features) are compiled in, the connection URL with the password hidden, and the detected backend. Because it never opens a connection, it's handy in CI or containers where the database isn't up yet but you want to confirm the settings are right.

cargo run -- db:info

db:dump [--out <path>] [--data-only|--schema-only] [--no-owner]

Backs up your database by running pg_dump against DATABASE_URL — like php artisan db:dump. By default the SQL goes to stdout (so you can pipe it); pass --out <path> (-o) to write a file instead. --data-only and --schema-only map straight to pg_dump's flags, and --no-owner drops the OWNER lines. You need pg_dump installed and on your PATH.

cargo run -- db:dump > backups/before-migrate.sql    # stdout → file
cargo run -- db:dump --out backups/before-migrate.sql

The running: pg_dump … status line goes to stderr, so it stays out of the redirect and out of a pipe. Until #1404 it went to stdout, which put it on the first line of the .sql file.

db:restore <path> [--clean]

Loads a dump file back into your database — the counterpart to db:dump. It runs the file through psql against DATABASE_URL with ON_ERROR_STOP=1, so it stops at the first error. Add --clean to wipe the existing schema first (it prepends DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;) so the restore lands on an empty database. You need psql on your PATH.

cargo run -- db:restore backups/before-migrate.sql
cargo run -- db:restore backups/before-migrate.sql --clean

System commands

version / --version

Prints the Rustango framework version.

$ cargo run -- version
rustango 0.60.0

about

Prints a snapshot of your environment: framework version, registered models and apps, whether the database is reachable, and key environment variables. Drop this into support tickets when something's wrong.

$ cargo run -- about
rustango
  version:        0.60.0
  models:         3 registered
  apps:           1 (blog)
  RUSTANGO_ENV:   local
  DATABASE_URL:   postgres://***@localhost:5433/myblog
  db_connect:     ok

check [--deploy]

Runs health checks on your project. Add --deploy for the stricter production-readiness checks.

Always-on checks:

  • ≥ 1 model registered via inventory
  • DB reachable (SELECT 1)
  • Models registered but no migrations on disk (it does not compare counts — an existing migrations/ directory is reported as info, whatever the number)

With --deploy:

  • RUSTANGO_ENV is prod or production
  • RUSTANGO_SESSION_SECRET set and ≥ 32 bytes (the HMAC key for cookies + JWTs; SECRET_KEY is never read by the framework)
  • DATABASE_URL set
  • RUSTANGO_APEX_DOMAIN set — the warning fires for every project when unset or localhost, and says so; single-tenant projects can ignore it
  • DATABASE_URL pointing at localhost / 127.0.0.1 (warning — usually a managed hostname in production)
  • RUSTANGO_BIND starting 127.0.0.1 (warning — loopback-only won't accept external traffic)
  • A settings-tier audit over your TOML, flagging dev defaults left in a prod tier (needs the config feature)
  • Meta.required_db_vendor / required_db_features on every registered model, checked against the dialect you are actually connected to
$ cargo run -- check --deploy
running rustango system check (deploy mode)...
  [info]    3 models registered via inventory
  [info]    database reachable
  [info]    4 migration(s) on disk
  [info]    RUSTANGO_SESSION_SECRET length OK
all checks passed

Exits non-zero if any error-level check fails. Warnings alone don't cause a failure.

docs

Opens the Rustango docs (https://docs.rs/rustango) in your browser. It always prints the URL too, so it still works on a headless server.

cargo run -- docs

--help / help

Lists every command with a one-line description. In tenancy mode, the multi-tenant commands listed below are added too.


Tenancy commands

These commands exist only in multi-tenant projects (one app serving many isolated customers/orgs). They show up only when the project is built with features = ["tenancy"] AND Cli::new() is chained with .tenancy().

Everything the operator console can do, these can do too — so an action can run in a deploy hook, a cron, or on a box where nobody can open a browser.

menu / actions

There are more than forty tenancy verbs. --help tells you they exist; the menu helps you run one you have never run before.

cargo run -- menu
  rustango manage — pick an action

  TENANTS
     1) list-tenants             every tenant in the registry
     2) create-tenant            provision a new tenant
     3) edit-tenant              change routing and display config
     …
  HOSTNAMES
     7) list-hosts               every hostname a tenant answers on
     …
     q  quit
     ?  every other verb: cargo run -- --help

Pick a number (or type the verb) and it asks only for what that verb will not ask for itself, echoes the command line it is about to run, runs it, and comes back for the next action. It re-enters the same dispatcher the flags go through, so it cannot drift from them.

Related but different: wizard is a one-time setup walk-through; menu is the standing list of what you can do afterwards.

wizard / init

Walks a fresh project from "I just scaffolded this" to a working tenant, an operator, and a tenant superuser. Every step is opt-in — press n to skip one.

cargo run -- wizard

init-tenancy

No-op — retained for compatibility. The framework no longer ships hand-built bootstrap migrations. Its own tables (rustango_orgs, rustango_operators, rustango_users, roles/permissions, …) are generated into system/migrations/ from the compiled models — the normal flow (models → makemigrations → migrate) — and applied by migrate / migrate-registry, which generate them on demand if the files are missing.

cargo run -- init-tenancy   # does nothing now; kept so old scripts don't break

Older versions wrote 0001_rustango_*_initial.json here; that hardcoded flow is gone. To provision, just run cargo run -- migrate. A custom user model declared on rustango_users flows through the same generated system/migrations/ — see Custom user model.

migrate-registry

Applies only the registry migrations — the shared, cross-tenant tables. The registry holds rustango_orgs and rustango_operators plus any registry-scoped tables you define. Tenant tables are untouched.

cargo run -- migrate-registry

migrate-tenants

Applies tenant migrations to every active tenant, one after another. Each tenant uses its own connection (its own schema or database), and if one tenant fails, the rest still run — the command reports the outcome per tenant at the end.

cargo run -- migrate-tenants

For the common case, plain migrate already does registry first, then tenants — reach for migrate-tenants only when you need that step on its own.

runserver / run-server

Starts the multi-tenant web server. In a tenancy project this is the same as bare cargo run; the named form exists so custom binaries that parse their own arguments can still trigger it.

cargo run                        # implicit
cargo run -- runserver           # explicit

On SIGTERM it stops accepting and gives open connections [server] shutdown_timeout_secs (default 20) to finish, then closes the rest. SSE and long-poll never finish by themselves. Provisioning and migration runs left running by a stopped process for over an hour are marked failed at the next boot, and a webhook retry with the same event_id runs again.

create-tenant <slug> [options]

Sets up a new tenant (customer/org) and applies the tenant migrations to it. The <slug> is its short identifier. Not safe to re-run: calling it again on an existing slug is refused up front with tenant slug `<slug>` already exists (tenancy/provision.rs:599), before anything else happens.

cargo run -- create-tenant acme --display-name "ACME Corp"
cargo run -- create-tenant beta --mode database --database-url postgres://...
FlagDescription
--display-name <name>Human-readable label shown in admin sidebars
--mode schema | databaseStorage mode (default: schema)
--database-url <url>Tenant-specific DB URL (required for database mode)
--host-pattern <pattern>Override the host pattern used by SubdomainResolver
--no-migrateSkip applying tenant-scoped migrations after provisioning
--backend postgres | mysql | sqliteDriver for a database-mode tenant (default: postgres). Validated against --mode
--schema-name <s>Override the generated schema name in schema mode
--port <n>Port the tenant is reachable on, for routing
--path-prefix <s>Path prefix the tenant is reachable under, for routing

edit-tenant <slug> [options]

Changes a tenant's routing and display config — the same fields the operator console's edit page offers.

cargo run -- edit-tenant acme --host-pattern shop.example.com
cargo run -- edit-tenant acme --display-name "ACME Inc" --deactivate
cargo run -- edit-tenant acme --clear host-pattern
FlagDescription
--display-name <name>Human-readable label
--host-pattern <host>Bare hostname the tenant answers on
--path-prefix <path>One leading-slash segment, e.g. /acme
--port <n>Port the tenant is matched on
--database-url <url>Rotate the tenant's connection URL
--activate / --deactivatePut the tenant in or out of service
--clear <field>Empty one of host-pattern, path-prefix, port

Only the fields you name are touched. "Leave alone" and "clear it" are different instructions, which is what --clear is for — it avoids relying on --host-pattern "", which some shells and CI runners eat.

Values are validated the way create-tenant validates them, so a host pattern carrying a port, or a path prefix the resolver could never produce, is refused rather than stored to silently never match.

Rotating --database-url changes the stored URL. Every server switches within 30 s, when its tenant cache refreshes; other edits leave warm connections alone. A secret rotated behind the same reference (vault, env var) changes nothing stored: restart the servers, or invalidate the tenant's pool on each one.

test-tenant-connection <url> [flags]

Probes a database URL before you commit to it — connects, and by default writes and rolls back, so a read-only credential is caught here rather than on the first tenant request.

cargo run -- test-tenant-connection postgres://user:pw@host/db
cargo run -- test-tenant-connection "$URL" --no-write-probe --timeout 5

drop-tenant <slug> [--confirm <slug>]

Deactivates a tenant by setting active = false. This is the soft, reversible option — the tenant's data stays on disk, and reactivate it with edit-tenant <slug> --activate. Re-running create-tenant does not work: the Org row still exists, so it is refused as a duplicate slug. When you're not running interactively (no terminal attached), you must pass --confirm <slug> with the slug typed again to confirm.

cargo run -- drop-tenant acme --confirm acme

purge-tenant <slug> [--confirm <slug>] [--purge-database]

Permanently deletes a tenant. It drops the tenant's schema and removes its row from rustango_orgs, with no undo. When you're not running interactively (no terminal attached), you must pass --confirm <slug> with the slug typed again. For database-mode tenants the command refuses outright unless you also pass --purge-database — it does not remove the Org row and leave the database behind, it does nothing at all (tenancy/manage/tenants.rs:479).

cargo run -- purge-tenant acme --confirm acme
cargo run -- purge-tenant beta --confirm beta --purge-database   # database-mode: also DROP DATABASE

With several servers, deactivate first (drop-tenant) and wait 30 s. A server whose tenant cache still holds a schema-mode tenant keeps search_path = <schema>, public; once the schema is dropped, its queries fall through to public until the cache refreshes.

list-tenants

Lists every tenant with its storage mode and active/inactive status.

cargo run -- list-tenants

Hostnames

A tenant is reachable at its subdomain and, optionally, at extra hostnames you bind to it. One hostname routes to exactly one tenant.

cargo run -- list-hosts acme
cargo run -- add-host acme shop.example.com
cargo run -- set-host-enabled acme shop.example.com --off   # park it
cargo run -- remove-host acme shop.example.com
VerbWhat it does
list-hosts <slug>Every hostname the tenant answers on, base host first
add-host <slug> <hostname>Bind one. Refused if another tenant already claims it
remove-host <slug> <hostname>Unbind one
set-host-enabled <slug> <hostname> --on|--offServe or park it

Parking (--off) keeps the row while taking the host out of service — useful while DNS propagates, or when retiring a domain you may want back.

Hostnames are normalized on the way in (lowercased, no scheme, no port, no path), because the stored value is compared byte-for-byte against the Host header. The verbs echo what was stored, not what you typed.

The base host comes from the tenant's host_pattern and has no row of its own, so it cannot be removed here — change it with edit-tenant --host-pattern. There is deliberately no rename-host: remove and add instead, so the change reaches other running pods.

create-operator <username> --password <pwd>

Creates an operator — a global admin who can manage every tenant from a cross-tenant console. Operators live in the shared registry, not inside any one tenant.

cargo run -- create-operator admin --password letmein
cargo run -- create-operator admin --generate     # print a random one instead

Omit --password on a terminal and it prompts without echoing.

list-operators

Every operator, with whether they are active and when they were created, plus a count of how many are still active.

cargo run -- list-operators

set-operator-active <username> --on|--off

Turns an operator's access off or back on. The row stays either way, so "who did this?" still resolves later — and the console re-reads it on every request, so a deactivation takes effect on their next click rather than whenever their cookie expires.

cargo run -- set-operator-active grace --off   # offboarding
cargo run -- set-operator-active grace --on

Two things it refuses, for the same reason the console refuses them:

  • Deactivating the last active operator. That locks everyone out of the console, and only a shell on the registry could undo it.
  • Deactivating yourself, when the request comes from the console. The CLI has no session to lock itself out of, so only the first rule applies there.

The direction is required — guessing would either revoke access or restore it, and both are wrong to do silently. Setting the state it already has reports that and succeeds, so a re-run provisioning script does not go red.

create-user <tenant> <username> --password <pwd> [--superuser]

Creates a user inside one tenant. The account exists only in that tenant.

cargo run -- create-user acme alice --password hunter2 --superuser

--superuser sets is_superuser = true for that user inside the tenant. That makes them an admin of the tenant (full write access in the tenant admin), but it never grants access to the cross-tenant operator console.

create-role <tenant> <name>

Creates a role (a named bundle of permissions) inside one tenant.

cargo run -- create-role acme editor

list-roles <tenant>

Lists the roles defined in a given tenant.

cargo run -- list-roles acme

assign-role <tenant> <username> <role>

Gives a user one of the tenant's roles.

cargo run -- assign-role acme alice editor

revoke-role <tenant> <username> <role>

Removes a role from a user — the reverse of assign-role.

cargo run -- revoke-role acme alice editor

grant-perm <tenant> <role-name|username> <codename> [--role]

Grants a single permission. By default the second argument is a username, so the permission goes straight to that user; add --role to grant it to a role instead. Permission codenames use the <app>.<action>_<model> format (blog.add_post, blog.change_post, …). The auto_create_permissions feature creates the four standard CRUD codenames automatically for any model marked #[rustango(permissions)].

cargo run -- grant-perm acme alice blog.change_post           # grant to user alice
cargo run -- grant-perm acme editor blog.change_post --role   # grant to role editor

revoke-perm <tenant> <role-name|username> <codename> [--role]

Removes a permission — the reverse of grant-perm. Targets a user by default; add --role to revoke it from a role instead.

cargo run -- revoke-perm acme alice blog.change_post
cargo run -- revoke-perm acme editor blog.change_post --role

create-api-key <tenant> <username> [--label <s>]

Issues an API key for a tenant user. The full token is printed once and never again — copy it now, because only its prefix and a hash are stored.

cargo run -- create-api-key acme alice --label "ci-bot"

audit-cleanup

Prunes old entries from the audit log (rustango_audit_log) to keep it from growing forever. Trim by age (--days) or by count (--keep-last), and optionally limit it to one tenant.

cargo run -- audit-cleanup --days 90                       # delete > 90 days old
cargo run -- audit-cleanup --keep-last 50                  # keep most recent 50 per row
cargo run -- audit-cleanup --keep-last 50 --tenant acme    # scoped
cargo run -- audit-cleanup --registry --days 90            # the registry's log only

By default it sweeps the registry's own log and every active tenant's. The registry log is where the operator console records what operators did, so it grows with console use. Naming one tenant (--tenant) asks for that tenant and leaves the registry alone. One broken tenant is reported and counted rather than ending the sweep.

Inspecting what happened

Three read-only verbs that print what the console renders — the answers you want during an incident, without a browser or a SQL client.

cargo run -- list-runs                        # recent provisioning + migration runs
cargo run -- list-runs --kind migrate --limit 50
cargo run -- show-run 42                      # one run's steps
cargo run -- audit-log                        # who changed what, and when
cargo run -- audit-log --pk acme --limit 100
VerbFlags
list-runs--limit <n>, --kind provision|migrate, --state <s>
show-run <id>—
audit-log--limit <n>, --table <t>, --pk <v>, --operation <o>, --source <s>

list-runs is newest-first and its filters run in the query, so asking for a migrate run finds one even when the most recent runs are all provisions. show-run prints the run's header and every step it recorded, which is what tells you where a failure happened.

Tenants created from the CLI are recorded too, tagged requested_by = cli, so the history covers both surfaces.

prewarm-pools

Opens a connection for every active database-mode tenant, up front. Worth running after a deploy, a registry restart, or a credential rotation: it turns "the first request to each tenant pays the connect" into one deliberate wait, and surfaces an unreachable tenant before a user finds it.

cargo run -- prewarm-pools

The operator console has a button for this too, on the tenant list.


Custom user model (extra columns on rustango_users)

This is how you add your own fields to the user table. The built-in tenant User has seven fixed columns: id, username, password_hash, is_superuser, active, created_at, plus a data JSONB column (a flexible JSON blob) for any extra per-user metadata. For most apps that JSONB column is all you need — no migration, no override, no surprises.

When you want typed, indexable columns on rustango_users instead, there are two approaches. They're not interchangeable; pick the one that fits where your project is in its life.

Option 1 — Sibling profile model with FK (works on any project)

Best when the project already exists, or when you'd rather leave the framework's User table as the single source of truth.

#[derive(rustango::Model)]
pub struct UserProfile {
    #[rustango(primary_key)] pub id: rustango::sql::Auto<i64>,
    #[rustango(fk = "rustango_users")] pub user_id: i64,
    #[rustango(max_length = 128, default = "''")] pub display_name: String,
    #[rustango(max_length = 64, default = "'UTC'")] pub timezone: String,
}

Run cargo run -- makemigrations then cargo run -- migrate, and you have a typed extras table linked to the user by foreign key. Read it with the ORM:

let profile = UserProfile::objects()
    .where_(UserProfile::user_id.eq(user.id.get().copied().unwrap()))
    .first(&pool).await?;            // Option<UserProfile>

Tradeoff: one extra row and a JOIN on every access. Upside: zero risk of breaking framework auth.

Option 2 — Cli::user_model::<AppUser>() (greenfield only)

Use this only on a fresh project where you want the extra fields right on the rustango_users table itself. Because AppUser is the rustango_users model, its columns flow through the ordinary makemigrations → migrate engine: the framework's tables are generated into system/migrations/, so AppUser's columns land in the generated CREATE TABLE rustango_users.

Step 1. Define your model. It has to declare every framework-required column exactly (id, username, password_hash, is_superuser, active, created_at, data, password_changed_at, sessions_revoked_at), plus your extras. Each extra column must either allow NULL or have a default = "…".

use rustango::sql::Auto;

#[derive(rustango::Model, Debug, Clone)]
#[rustango(table = "rustango_users")]
pub struct AppUser {
    #[rustango(primary_key)] pub id: Auto<i64>,
    #[rustango(max_length = 64, unique)] pub username: String,
    #[rustango(max_length = 255)] pub password_hash: String,
    pub is_superuser: bool,
    pub active: bool,
    pub created_at: chrono::DateTime<chrono::Utc>,
    #[rustango(default = "'{}'")] pub data: serde_json::Value,
    pub password_changed_at: Option<chrono::DateTime<chrono::Utc>>,
    pub sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
    // extras —
    #[rustango(max_length = 128, default = "''")] pub display_name: String,
    #[rustango(max_length = 64, default = "'UTC'")] pub timezone: String,
}
impl rustango::tenancy::TenantUserModel for AppUser {}

Step 2. Wire the override into main.rs:

#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    rustango::manage::Cli::new()
        .api(my_app::urls::router())
        .tenancy()
        .user_model::<AppUser>()
        .run().await
}

user_model checks the model at startup and panics if a required column is missing; declaring the model on rustango_users is what selects it.

Step 3. Register AppUser instead of the framework User — only one model may claim table = "rustango_users". The scaffolder ships no static bootstrap JSON (just an empty system/migrations/), so there's nothing to delete; just don't also register the framework User.

Step 4. Generate + apply:

cargo run -- makemigrations       # generates system/migrations/ with AppUser's columns
cargo run -- migrate              # creates rustango_users with your extras

Caveats:

  • Changing AppUser later is a normal schema change: re-run makemigrations to emit the AddColumn migration, then migrate.
  • Only one model may map to rustango_users. Registering both the framework User and your AppUser makes makemigrations ambiguous — register AppUser alone. This is the main reason Option 2 is for fresh projects only; on an existing project, Option 1 avoids the problem.
  • Framework auth and admin code reads the nine core columns by name; your extra columns are reachable only through AppUser::objects().fetch(...).

Builder::user_model::<AppUser>() does the same thing for code that builds the server Builder directly, without going through Cli.


Custom subcommands

You can add your own management commands. The trick is to inspect the arguments yourself and handle your command before passing the rest to Cli::run. Two ways to do it:

Inline in src/main.rs (no extra binary):

#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let _ = dotenvy::dotenv();
    let args: Vec<String> = std::env::args().skip(1).collect();
    if matches!(args.first().map(String::as_str), Some("import-csv")) {
        let url = std::env::var("DATABASE_URL")?;
        let pool = rustango::sql::Pool::connect_postgres(&url).await?;
        return my_csv_importer::run(&pool, &args[1..]).await;
    }
    rustango::manage::Cli::new().api(urls::api()).run().await
}

Via --with-manage-bin (separate src/bin/manage.rs):

cargo run -- startapp app --with-manage-bin

Then in src/bin/manage.rs:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let _ = dotenvy::dotenv();
    let args: Vec<String> = std::env::args().skip(1).collect();
    let url = std::env::var("DATABASE_URL")?;
    let pool = rustango::sql::Pool::connect_postgres(&url).await?;

    match args.first().map(String::as_str) {
        Some("import-csv") => my_csv_importer::run(&pool, &args[1..]).await,
        _ => rustango::migrate::manage::run(&pool, "./migrations".as_ref(), args)
            .await
            .map_err(Into::into),
    }
}

Run your own commands just like the built-in ones: cargo run -- import-csv path/to/file.csv (or cargo run --bin manage -- import-csv … when using --with-manage-bin).


Common workflows

First-time project setup (single-tenant)

cargo rustango new myapp
cd myapp
cp .env.example .env             # edit DATABASE_URL
docker compose up -d
cargo run -- migrate
cargo run                        # serve at :8080

First-time project setup (tenancy)

cargo rustango new myapp --template tenant
cd myapp
cp .env.example .env             # edit DATABASE_URL + RUSTANGO_APEX_DOMAIN
docker compose up -d
cargo run -- migrate                                      # registry + tenants
cargo run -- create-operator admin --password letmein
cargo run -- create-tenant acme --display-name "ACME Inc" \
                  --host-pattern acme.localhost
cargo run -- create-user acme alice --password tenantpw --superuser
cargo run                        # serve at :8080

Adding tenants after the app is already running

A real tenancy app usually builds up models and migrations long before its first tenant signs up. This flow works at any point in the project's life:

# 1. (any time) develop user models — define structs with #[derive(Model)],
#    add `pub mod ...;` to src/lib.rs.
# 2. Generate scope-aware migrations. In a tenancy project this writes
#    up to TWO files: one tagged registry-scope (touches Org/Operator),
#    one tagged tenant-scope (touches User + your models). Pre-v0.24.2
#    this used to dump everything into one tenant-scoped file and
#    crash on `create-tenant` — see the changelog.
cargo run -- makemigrations

# 3. Apply migrations. `migrate` is scope-aware: it runs registry-
#    scoped files once against the registry pool first, then fans
#    tenant-scoped files across every active tenant.
cargo run -- migrate

# 4. Provision a NEW tenant whenever (could be days, weeks, many
#    migrations later). The tenancy code applies every accumulated
#    tenant-scoped migration to the new tenant's schema in one pass —
#    the new tenant arrives at the same schema state as existing ones.
cargo run -- create-tenant acme --display-name "ACME Inc" \
                  --host-pattern acme.localhost
cargo run -- create-user acme alice --password tenantpw --superuser

Why this is safe:

  • #[rustango(scope = "registry")] on Org/Operator keeps changes to shared tables out of the per-tenant migrations.
  • migrate-tenants visits every active tenant and applies only the tenant migrations — registry files are skipped.
  • create-tenant runs that same migrate-tenants pass against the new tenant's schema, so it starts fully up to date with no manual fixup.

Add a model

cargo run -- startapp blog        # if not done yet
# Edit src/blog/models.rs — add #[derive(Model)]
# Add `pub mod blog;` to src/lib.rs
cargo run -- makemigrations
cargo run -- migrate

Add a JSON API for that model

cargo run -- make:viewset PostViewSet --model Post
# Edit src/post_view_set.rs — fill in field lists
# Mount in src/urls.rs
cargo run                        # GET /api/posts now works

Add a data backfill

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

Pre-deploy audit

cargo run --release -- check --deploy

Roll back the last migration

cargo run -- downgrade 1

Apply a tenancy migration to one specific scope

cargo run -- migrate-registry            # registry-scoped only
cargo run -- migrate-tenants             # tenant-scoped, fan-out across orgs

Decommission a tenant

cargo run -- drop-tenant acme            # soft (reversible)
cargo run -- purge-tenant acme           # hard (drops schema/db)

Tenant-pool tuning (v0.27.7+)

Database-mode tenants get their own connection pool (a PgPool — a set of reused database connections), cached by slug in TenantPools. By default a pool is built lazily, on the tenant's first request, unless you turn on pre-warming. The settings live on TenantPoolsConfig:

FieldDefaultPurpose
max_cached_database_pools64Pool cache cap. Once full, the next uncached tenant errors out (no silent eviction).
database_pool_max_connections16Per-pool max_connections. Keep small so a tenant fan-out doesn't exhaust PG max_connections.
database_pool_min_connections0Keeps N connections warm at all times. ≥1 drops first-request latency by paying the TCP/TLS/auth round-trip at boot.
database_pool_acquire_timeout30sHow long pool.acquire() waits before erroring PoolTimedOut.
database_pool_idle_timeout10 minClose idle connections after this duration. Defends against load-balancer / idle_in_transaction_session_timeout cuts.
database_pool_max_lifetime30 minForce-rotate connections so vault-leased credentials get refreshed.
prewarm_active_tenantsfalseWhen true, Server::Builder::serve calls prewarm_database_tenants() at boot.

Pre-warm at boot

Two ways to trigger:

  1. Automatic — set prewarm_active_tenants = true on the TenantPoolsConfig you hand TenantPools::new(...).config(...). Server::Builder::serve runs the pre-warm before binding.

  2. CLI verb — cargo run -- prewarm-pools builds pools for every active database-mode tenant and exits. Useful as a post-deploy hook (e.g. after credential rotation), or to validate every tenant is reachable before flipping a load balancer.

Pre-warm walks Org::objects().where(active = true, storage_mode = "database") and short-circuits when the cache cap is reached (reported as skipped_cap in the [PrewarmReport]). Per-tenant build failures log a tracing::warn! but don't abort the loop.

Tracing

tenant_pool_init is a tracing::info_span! that wraps the cold-path pool build, and the events inside it carry the rustango::tenancy::pools target. Subscribe to see per-tenant build latency:

INFO rustango::tenancy::pools: tenant pool connected (database mode)
     slug=acme elapsed_ms=42 min_conn=1 max_conn=4

Turn it on with RUST_LOG=rustango::tenancy::pools=info. A filter on crate::tenancy::pools matches nothing — a target is a string, not a path; see Logging.

Setup gotcha — macOS .local TLDs

If you hit the tenant admin via http://acme.local:8080/admin/ on macOS and see a 5-second pause on every request: that's Bonjour / mDNS, not Rustango. macOS's resolver treats .local specially and waits the full mDNS timeout before falling back to /etc/hosts. Two fixes:

  1. Use a different TLD: 127.0.0.1 acme.localhost works without delay. localhost is reserved (RFC 6761) and skips mDNS.
  2. Run dnsmasq with a .local zone pointing at 127.0.0.1 so the OS gets an immediate answer.

Confirm with curl -w "%{time_connect}\n": if time_connect shows ~5s but it drops to milliseconds with --resolve acme.local:8080:127.0.0.1, you're hitting mDNS.


See also