This version is in beta. Some features may change before release.

Management commands

serve, migrate, makemigrations, inspectdb - every command runs against your project's own binary.

Every management subcommand runs against your project's own binary. Your main.rs hosts the commands via umbral_cli::dispatch; cargo run -- <command> is how you invoke them.

This is a project-local entry point that runs your own project code, so the commands see your models and your plugins. The framework-global umbral binary handles scaffolding (startproject, startapp) and nothing else - it can't manage your database because it doesn't know about your models.

The built-in subcommands

serve

Boot the App and serve HTTP. The default when no subcommand is given.

makemigrations

Diff registered plugin models against the latest snapshot. Writes a migration file per plugin with changes.

migrate

Apply every pending migration against the ambient pool. Each migration runs in its own transaction.

showmigrations

Print applied/pending state per plugin. [X] applied, [ ] pending.

checkmigrations

Classify pending operations SAFE/WARNING/UNSAFE for zero-downtime deploys. Exits non-zero on unsafe ops, a CI gate before migrate.

inspectdb

Introspect an existing database into models.rs + a 0001_initial migration. The porting on-ramp.

dumpdata / loaddata

Snapshot every model's rows to JSON; replay them into a fresh schema.

importcsv

Load a CSV file into one table, coercing each cell to its column type through the validated write path. Inverse of the ?format=csv export.

maskkeygen

Generate an X25519 keypair for Masked<T> field encryption and print the two env-var lines to configure it.

dev

Auto-reload loop: wraps cargo-watch to rebuild and restart the binary on every Rust-source save.

Plugin-contributed subcommands

Built-in plugins contribute their own subcommands through the Plugin::commands() hook - structurally identical to a third-party plugin's. They only appear when the plugin is registered in your App.

createsuperuser

umbral-auth. Create an is_staff + is_superuser user with an argon2id-hashed password. --noinput for CI.

tasks-worker

umbral-tasks. Drain the task queue. --once runs one batch and exits (cron-friendly).

tasks-beat

umbral-tasks. Run the periodic-task scheduler. --once syncs schedules, runs one tick, exits.

clearsessions

umbral-sessions. Delete all expired session rows from the database.

collectstatic

umbral-storage. Collect every plugin's static assets into static_root. --clear / --hashed / --storage.

Each plugin command shares the same cargo run -- <cmd> form and is detailed in its own section below.

How it works

Your src/main.rs (the one umbral startproject generates) ends with:

Code
rust
let app = App::builder()
.settings(settings)
.database("default", pool)
.model::<Article>()
.plugin(blog::BlogPlugin::default())
.build()?;
 
umbral_cli::dispatch(app).await

dispatch(app) parses argv, picks the subcommand, and runs it against the already-built App. The pool, model registry, and plugin list are all published by App::build before the subcommand fires, so every command sees the same state your server would.

Run them with cargo run --:

Code
bash
cargo run -- serve # boot the HTTP server
cargo run -- migrate # apply pending migrations
cargo run -- makemigrations # write a new migration file
cargo run -- showmigrations # list per-plugin state

Settings come from the environment

Settings load the same way the server does - defaults, an optional umbral.toml, and UMBRAL_-prefixed environment variables (last wins).

Code
bash
export UMBRAL_SECRET_KEY=$(openssl rand -hex 32)
export UMBRAL_DATABASE_URL=sqlite://app.db?mode=rwc
 
cargo run -- makemigrations
cargo run -- migrate
Warning

Without UMBRAL_DATABASE_URL set, the management subcommands fall back to the database_url in umbral.toml (or sqlite::memory: if that's also unset). migrate will happily report Applied 1 migration(s) against an in-memory pool that vanishes when the process exits. Set the URL once in umbral.toml or your shell, and forget about it.

serve - boot the HTTP server

Default subcommand. If you cargo run with no subcommand, this is what fires.

Code
bash
# Default: 127.0.0.1:8000 (from settings.bind_addr)
cargo run -- serve
 
# Bind to a specific port on localhost
cargo run -- serve --addr 127.0.0.1:8080
 
# Bind to all interfaces (production / Docker / LAN)
cargo run -- serve --addr 0.0.0.0:8080
 
# Same effect via env var (useful in docker-compose / systemd)
UMBRAL_BIND_ADDR=0.0.0.0:8080 cargo run -- serve
 
# Or set it once in umbral.toml and never pass --addr again
# umbral.toml:
# bind_addr = "0.0.0.0:8080"
FlagDefaultEffect
--addr <host:port>127.0.0.1:8000Override settings.bind_addr. 0.0.0.0:PORT listens on every interface; 127.0.0.1:PORT only loopback. Equivalent to setting UMBRAL_BIND_ADDR.

Precedence: --addr flag wins over UMBRAL_BIND_ADDR wins over umbral.toml's bind_addr wins over the built-in default.

dev - auto-reload loop for development

dev wraps cargo-watch to rebuild and restart the binary every time you save a Rust file. Templates already hot-reload in-process when settings.environment == Dev, so this is specifically for the .rs edit → recompile → restart cycle.

Code
bash
# One-time install (only needed if you don't already have cargo-watch)
cargo install cargo-watch
 
# Default: watch src/ and Cargo.toml, re-run `cargo run`
cargo run -- dev
 
# Watch additional paths beyond the cargo-watch defaults. Each path
# needs its own -w flag. Comma-separated values are NOT supported -
# `-w src/,templates/` is read as one path literally named
# "src/,templates/" and won't match anything.
cargo run -- dev -w templates/ -w plugins/
 
# Run a non-default cargo command on each change. The `--` separator
# is required; everything after it gets handed to `cargo run --`.
cargo run -- dev -- migrate # re-runs `cargo run -- migrate` on save
cargo run -- dev -- serve --addr 0.0.0.0:8080
FlagDefaultEffect
-w <path> / --watch <path>cargo-watch's own (src/, Cargo.toml)Add a path to the watch list. Repeat the flag for multiple paths - -w plugins/ -w migrations/. Hyphenated paths like --watch=plugins/ also work.
-- <run_args>...cargo run with no argsPass-through args for the inner cargo command. Everything after -- is forwarded to cargo run --.

Without cargo-watch installed, umbral dev prints the install hint and exits 1 - no cryptic failure, no half-started server.

Info

You don't need dev for template edits. When settings.environment == Dev, the templates engine re-reads .html files from disk on every render. Save a template, refresh the browser, see the change - no restart, no dev subcommand. dev is only for Rust-source edits that need a recompile.

The fallback if you don't want another global tool: leave cargo run running in a terminal and Ctrl-C + arrow-up + enter after each .rs edit. The keystrokes are what dev saves you, nothing more.

makemigrations - generate migration files

Diffs the live model registry against the latest snapshot on disk and writes a new migration file per plugin that has changes.

Code
bash
# Everyday loop: change a model in src/, then:
cargo run -- makemigrations
# -> Wrote migrations/app/0002_alter_post_add_slug.json
 
# Nothing to migrate? It says so and exits 0.
cargo run -- makemigrations
# -> no changes detected

No flags. The output is one Wrote <path> line per plugin with changes. Apply the new files with migrate.

migrate - apply pending migrations

Code
bash
# Everyday: apply everything pending.
cargo run -- migrate
 
# Adopting an existing database that wasn't built with umbral:
cargo run -- migrate --fake-initial
 
# Recovering from a deleted migration file: mark it applied
# manually (the schema is already in the DB).
cargo run -- migrate --fake app/0003_add_post_tag
 
# Skip the drift error and apply only the pending ones (a
# teammate deleted a migration file you've already applied
# locally - proceed anyway).
cargo run -- migrate --allow-drift
FlagDefaultEffect
(none)apply pendingApply every pending migration. Errors on drift (applied-but-missing-on-disk).
--fake <plugin/name>-Mark one migration as applied in the tracking table WITHOUT running its SQL. Format: app/0001_create_post. Recovery path when the schema already exists outside umbral's control.
--fake-initialoffPer plugin: if the 0001_* migration's target tables already exist in the database, mark it applied without running SQL. Use when onboarding a database bootstrapped by hand or by another tool.
--allow-driftoffProceed even when some applied migrations are missing from disk. Logs a warning per missing file and applies the genuinely-pending ones.

Worked examples for each flag live in the migration drift recovery guide.

showmigrations

Code
bash
cargo run -- showmigrations

Prints each migration's state, grouped by plugin:

Code
txt
# plugin: app
[X] app/0001_create_post - applied
[ ] app/0002_add_post_slug - pending
[!] app/0003_add_post_tag - applied but file missing on disk (drift)
[?] app/0004_add_post_body - on disk but out of order
MarkerState
[X]Applied - in the tracking table and the file exists on disk.
[ ]Pending - on disk, newer than the last applied migration.
[!]Applied but missing on disk - tracking table is ahead of VCS. Critical drift.
[?]Out of order - on disk, not applied, but older than the last applied migration. Warning only.

checkmigrations - zero-downtime safety gate

Classifies every pending operation SAFE / WARNING / UNSAFE so you know what's risky to apply while old code is still serving traffic. Read-only; it applies nothing.

Code
bash
cargo run -- checkmigrations
Code
txt
Checking 3 operation(s) across 2 pending migration(s)...
 
UNSAFE (1):
[DROP COL] app/0007_drop_legacy: drops column `order.legacy_total` and its data; old code reading it breaks. Expand-contract: stop writing it, deploy, then drop
 
Summary: 2 safe, 0 warning, 1 unsafe.

Exits non-zero when any UNSAFE op is found (or any WARNING under --strict), so it gates a deploy pipeline before migrate runs.

FlagDefaultEffect
(none)fail on unsafeExit non-zero only when a destructive op (DROP TABLE/COLUMN/M2M) is pending.
--strictoffAlso exit non-zero on WARNING-tier ops (renames, alters, NOT-NULL-no-default adds).

Full tier rules + the expand-contract pattern: Checking migrations for zero-downtime safety.

inspectdb - generate models from an existing database

The porting on-ramp. Reads the connected database's schema and writes a models.rs + a 0001_initial migration that captures it.

Code
bash
# Point at an existing database, scaffold models into a plugin dir
UMBRAL_DATABASE_URL=postgres://user:pass@localhost/legacy \
cargo run -- inspectdb --output plugins/imported
 
# Same, plus mark the migration as already-applied so the next
# `migrate` is a no-op (the tables already exist).
UMBRAL_DATABASE_URL=postgres://user:pass@localhost/legacy \
cargo run -- inspectdb --output plugins/imported --mark-applied
FlagDefaultEffect
--output <path>- (required)Where models.rs and migrations/app/0001_initial.json land. The directory is created if it doesn't exist.
--mark-appliedoffRecord 0001_initial in the umbral_migrations tracking table without running its SQL. Pass when the target DB already holds the introspected tables - without this flag, the next migrate would try to CREATE TABLE over them.

Full porting workflow in the inspectdb guide.

dumpdata / loaddata - JSON snapshot and restore

Code
bash
# Snapshot every registered model's rows to one JSON envelope
cargo run -- dumpdata --output backup.json
 
# Replay a snapshot into a fresh DB (run `migrate` first to
# build the schema)
cargo run -- migrate
cargo run -- loaddata backup.json

dumpdata walks the registered models in topological order so foreign-key parents land before children; loaddata inserts in the same order. The envelope is portable across SQLite and Postgres - dump from one, load into the other.

CommandFlag / ArgDefaultEffect
dumpdata--output <path>- (required)Where the JSON envelope is written. Existing file is overwritten.
loaddata<input> (positional)- (required)Path to a dumpdata JSON envelope.

Use case: the upgrade-safety snapshot. Dump before a risky migration; if the migration goes sideways, drop the DB, migrate from scratch, loaddata the snapshot.

importcsv - load a CSV into one table

The inverse of the REST list endpoint's ?format=csv export. Reads a CSV file and inserts its rows into one table, coercing each cell to the column's type and routing through the same validated write path as a REST POST - so validators, auto_now, slug_from, and FK-existence checks all apply per row.

Code
bash
cargo run -- importcsv blog_post posts.csv
Code
text
Imported 142 row(s) into `blog_post` (2 failed)
line 17: title: This field is required.
line 88: author: no Author with id=999

The first CSV line is the header and names the columns; a header that matches no model field is ignored (so an extra column, or a re-ordered export, imports cleanly). Cells coerce by column type: integer/float/boolean columns parse from their text, an empty cell on a nullable column becomes NULL, a Json column parses as JSON, and everything else (text, dates, UUIDs) passes through as a string.

Import is best-effort: each row commits independently, a failing row is reported by its 1-based line number and skipped, and the rest still import. The command exits non-zero when any row failed, so a script catches a partial import. (Rows are not wrapped in one transaction - the dynamic write path has none yet; see planning/orm_fixes.md #2.)

CommandArgDefaultEffect
importcsv<table> (positional)- (required)Target table name (e.g. blog_post). A typo lists the valid tables.
importcsv<input> (positional)- (required)Path to the CSV file. Must have a header row.

maskkeygen - keypair for Masked<T> fields

Generates a fresh X25519 keypair for Masked<T> field encryption and prints the two env-var lines that configure it. The public key encrypts; the private key decrypts (reveal()).

Code
bash
cargo run -- maskkeygen
# -> UMBRAL_MASK_PUBLIC_KEY=...
# -> UMBRAL_MASK_PRIVATE_KEY=...

No flags. Set both lines in your environment (or .env) before booting an app that has Masked<T> fields.

createsuperuser - umbral-auth

Interactive prompt for username / email / password; creates a row in auth_user with is_staff = is_superuser = true and the password argon2id-hashed.

Code
bash
# Interactive (everyday)
cargo run -- createsuperuser
# Prompts for username, email, password (twice for confirmation).
 
# Non-interactive (CI / first-deploy scripts)
UMBRAL_SUPERUSER_PASSWORD='strong-password' \
cargo run -- createsuperuser \
--username alice \
--email alice@example.com \
--noinput
FlagWhenEffect
--username <name>non-interactiveSkip the username prompt; use this value.
--email <addr>non-interactiveSkip the email prompt; use this value.
--noinputnon-interactiveNo prompts at all. Username, email, and UMBRAL_SUPERUSER_PASSWORD must all be set or the command errors.

Password is read from UMBRAL_SUPERUSER_PASSWORD in --noinput mode (never via a flag - flags land in shell history).

tasks-worker - umbral-tasks

Drain the task queue. Without flags, loops forever. With --once, processes one batch and exits - cron-friendly.

Code
bash
# Long-running worker (run under systemd / Docker)
cargo run -- tasks-worker
 
# Single-batch (call from cron every minute)
cargo run -- tasks-worker --once
FlagDefaultEffect
--onceoff (loop forever)Run one iteration of the claim/dispatch loop and exit. Cron-friendly.

tasks-beat - umbral-tasks

Run the periodic-task scheduler. On startup it syncs the registered periodic specs to PeriodicTask rows, then each tick claims every due row atomically and enqueues the underlying task. Run it as its own process alongside tasks-worker - beat fills the queue, the worker drains it.

Code
bash
# Long-running scheduler (run under systemd / Docker, one instance)
cargo run -- tasks-beat
 
# Single sync + one tick, then exit (tests, cron-driven beats)
cargo run -- tasks-beat --once
FlagDefaultEffect
--onceoff (poll forever)Sync schedules, run one tick, and exit.

clearsessions - umbral-sessions

Delete every expired session row from the database. Run it periodically (cron / a scheduled task) to keep the session table from accumulating dead rows.

Code
bash
cargo run -- clearsessions
# -> Deleted 37 expired session(s).

No flags. Deletes session rows whose expires_at is in the past.

collectstatic - umbral-storage

Collect every plugin's static assets - namespaced static_dirs() plus site-level static_root_dirs() - into settings.static_root.

Code
bash
# Everyday: gather all assets into static_root
cargo run -- collectstatic
 
# Empty static_root first, dropping stale assets no plugin ships any more
cargo run -- collectstatic --clear
 
# PROD: write content-hashed copies (app.<hash>.css) + a staticfiles.json
# manifest, so assets can carry far-future cache headers without stale-cache risk
cargo run -- collectstatic --hashed
FlagDefaultEffect
--clearoffEmpty static_root before collecting (no confirmation prompt), dropping assets no plugin ships any more.
--hashedoffWrite content-hashed copies (app.<hash>.css) alongside each asset plus a staticfiles.json manifest for far-future cache headers. Use in production.
--storage <backend>localWhere to write: local (the on-disk static_root) or s3 (requires the umbral-storage s3 feature and UMBRAL_STATIC_BUCKET / UMBRAL_STATIC_REGION). Overrides UMBRAL_STATIC_STORAGE.

Errors

Every subcommand prints errors through the Display impl, not the Debug repr. A failure reads as a single human-readable diagnostic:

Code
txt
error: umbral inspectdb: column `doc.payload` has unsupported SQL type
`BLOB`; add a matching SqlType variant or edit the generated
model by hand

Exit code is 1 on any error, 0 on success.

Writing your own management command

Any plugin (built-in or third-party) can contribute a CLI subcommand that shows up under cargo run -- <name>. The mechanism is the Plugin::commands() hook returning a Vec<Box<dyn PluginCommand>>, where each command exposes a clap::Command and an async run handler. The framework's dispatcher composes everything under a single top-level clap parser so plugin commands are first-class citizens alongside the built-ins.

Code
rust
use clap::{Arg, ArgAction, ArgMatches, Command};
use umbral::cli::{CliError, PluginCommand};
use umbral::plugin::{AppContext, Plugin, PluginError};
use umbral::web::Router;
 
pub struct BlogPlugin;
 
impl Plugin for BlogPlugin {
fn name(&self) -> &'static str { "blog" }
fn routes(&self) -> Router { Router::new() }
 
/// The hook that exposes your subcommand to `cargo run --`.
fn commands(&self) -> Vec<Box<dyn PluginCommand>> {
vec![Box::new(SeedBlogCommand)]
}
}
 
/// One management subcommand. The shape is identical to what
/// umbral-auth's `createsuperuser` or umbral-tasks's `tasks-worker`
/// use - there's no separate "framework command" vs "plugin
/// command" distinction.
#[derive(Debug, Default)]
pub struct SeedBlogCommand;
 
#[async_trait::async_trait]
impl PluginCommand for SeedBlogCommand {
/// Return the clap subcommand. The literal returned by
/// `Command::get_name()` is what the user types after `cargo
/// run --`.
fn command(&self) -> Command {
Command::new("seed-blog")
.about("Seed the blog with a few demo posts")
.arg(
Arg::new("count")
.long("count")
.help("How many posts to insert")
.default_value("3"),
)
.arg(
Arg::new("dry-run")
.long("dry-run")
.help("Print the rows without writing them")
.action(ArgAction::SetTrue),
)
}
 
/// Run the command. The ambient pool and model registry are
/// already published - `Post::objects()`, `Post::objects()
/// .bulk_create(...)`, etc. all Just Work.
async fn run(&self, matches: &ArgMatches) -> Result<(), CliError> {
let count: u64 = matches
.get_one::<String>("count")
.unwrap()
.parse()
.map_err(|e: std::num::ParseIntError| -> CliError { Box::new(e) })?;
let dry_run = matches.get_flag("dry-run");
 
let posts: Vec<Post> = (1..=count)
.map(|i| Post {
id: 0,
title: format!("Demo post {i}"),
body: "Lorem ipsum.".into(),
})
.collect();
 
if dry_run {
for p in &posts {
println!("would insert: {}", p.title);
}
return Ok(());
}
 
let inserted = Post::objects()
.bulk_create(posts)
.await
.map_err(|e| -> CliError { Box::new(e) })?;
println!("Inserted {inserted} demo post(s)");
Ok(())
}
}

Wire it up in main.rs as usual:

Code
rust
let app = App::builder()
.plugin(BlogPlugin)
.build()?;
 
umbral_cli::dispatch(app).await

Then:

Code
bash
cargo run -- seed-blog --count 10 --dry-run
# -> would insert: Demo post 1
# -> would insert: Demo post 2
# ...
 
cargo run -- seed-blog --count 10
# -> Inserted 10 demo post(s)

How dispatch routes args

When cargo run -- <name> runs, the project's binary calls umbral_cli::dispatch(app). The dispatcher:

  1. Walks every registered plugin in topological order, collecting each one's Plugin::commands() Vec.
  2. Builds a single top-level clap parser with every plugin command as a subcommand.
  3. Parses argv. If a plugin command matches, calls that command's run(&matches).
  4. If nothing matches, the framework's built-in subcommands (serve, migrate, inspectdb, …) take over.

A typo (cargo run -- creatsuperuser) surfaces as a clap parse error with a "did you mean createsuperuser?" suggestion, since clap sees every subcommand at once.

What to put in run

The handler runs after App::build() has finished, so:

  • umbral::db::pool() returns the live pool.
  • The model registry is populated; MyModel::objects().fetch().await works.
  • Settings are live; umbral::settings::get() returns the resolved values.
  • Every other plugin's on_ready has fired (commands run after on_ready by design - see the dispatch order in crates/umbral-core/src/cli.rs).

What NOT to do: don't start the HTTP server, don't run migrations yourself. Those are separate subcommands; your command should do one thing and exit.

Calling conventions

  • Exit code 0 on success. Return Ok(()).
  • Exit code 1 on failure. Return Err(CliError) - the dispatcher prints the error and sets the code.
  • Read from stdin / write to stdout if you want pipe-friendly output. Use stderr for progress messages so they don't pollute pipes.
  • Interactive prompts are fine but ALWAYS provide a --noinput flag that reads from env vars instead, so CI / containers / Ansible can call your command non-interactively. createsuperuser is the canonical example.

What's next

The deep spec for each subcommand lives alongside its subsystem: docs/specs/06-migration-engine.md for the migrate trio, docs/specs/07-inspectdb.md for inspectdb. The PluginCommand trait lives at crates/umbral-core/src/cli.rs.

See Scaffolding a project for the global umbral binary that creates src/main.rs + the umbral.toml config in the first place.