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:
let app = App::builder() .settings(settings) .database("default", pool) .model::<Article>() .plugin(blog::BlogPlugin::default()) .build()?; umbral_cli::dispatch(app).awaitdispatch(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 --:
cargo run -- serve # boot the HTTP servercargo run -- migrate # apply pending migrationscargo run -- makemigrations # write a new migration filecargo run -- showmigrations # list per-plugin stateSettings come from the environment
Settings load the same way the server does - defaults, an optional
umbral.toml, and UMBRAL_-prefixed environment variables (last wins).
export UMBRAL_SECRET_KEY=$(openssl rand -hex 32)export UMBRAL_DATABASE_URL=sqlite://app.db?mode=rwc cargo run -- makemigrationscargo run -- migrateWithout 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.
# Default: 127.0.0.1:8000 (from settings.bind_addr)cargo run -- serve # Bind to a specific port on localhostcargo 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"| Flag | Default | Effect |
|---|---|---|
--addr <host:port> | 127.0.0.1:8000 | Override 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.
# 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 savecargo run -- dev -- serve --addr 0.0.0.0:8080| Flag | Default | Effect |
|---|---|---|
-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 args | Pass-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.
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.
# 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 detectedNo flags. The output is one Wrote <path> line per plugin with changes. Apply the new files with migrate.
migrate - apply pending migrations
# 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| Flag | Default | Effect |
|---|---|---|
| (none) | apply pending | Apply 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-initial | off | Per 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-drift | off | Proceed 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
cargo run -- showmigrationsPrints each migration's state, grouped by plugin:
# 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| Marker | State |
|---|---|
[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.
cargo run -- checkmigrationsChecking 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.
| Flag | Default | Effect |
|---|---|---|
| (none) | fail on unsafe | Exit non-zero only when a destructive op (DROP TABLE/COLUMN/M2M) is pending. |
--strict | off | Also 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.
# Point at an existing database, scaffold models into a plugin dirUMBRAL_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| Flag | Default | Effect |
|---|---|---|
--output <path> | - (required) | Where models.rs and migrations/app/0001_initial.json land. The directory is created if it doesn't exist. |
--mark-applied | off | Record 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
# Snapshot every registered model's rows to one JSON envelopecargo run -- dumpdata --output backup.json # Replay a snapshot into a fresh DB (run `migrate` first to# build the schema)cargo run -- migratecargo run -- loaddata backup.jsondumpdata 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.
| Command | Flag / Arg | Default | Effect |
|---|---|---|---|
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.
cargo run -- importcsv blog_post posts.csvImported 142 row(s) into `blog_post` (2 failed) line 17: title: This field is required. line 88: author: no Author with id=999The 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.)
| Command | Arg | Default | Effect |
|---|---|---|---|
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()).
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.
# 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| Flag | When | Effect |
|---|---|---|
--username <name> | non-interactive | Skip the username prompt; use this value. |
--email <addr> | non-interactive | Skip the email prompt; use this value. |
--noinput | non-interactive | No 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.
# Long-running worker (run under systemd / Docker)cargo run -- tasks-worker # Single-batch (call from cron every minute)cargo run -- tasks-worker --once| Flag | Default | Effect |
|---|---|---|
--once | off (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.
# 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| Flag | Default | Effect |
|---|---|---|
--once | off (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.
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.
# Everyday: gather all assets into static_rootcargo run -- collectstatic # Empty static_root first, dropping stale assets no plugin ships any morecargo 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 riskcargo run -- collectstatic --hashed| Flag | Default | Effect |
|---|---|---|
--clear | off | Empty static_root before collecting (no confirmation prompt), dropping assets no plugin ships any more. |
--hashed | off | Write content-hashed copies (app.<hash>.css) alongside each asset plus a staticfiles.json manifest for far-future cache headers. Use in production. |
--storage <backend> | local | Where 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:
error: umbral inspectdb: column `doc.payload` has unsupported SQL type `BLOB`; add a matching SqlType variant or edit the generated model by handExit 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.
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:
let app = App::builder() .plugin(BlogPlugin) .build()?; umbral_cli::dispatch(app).awaitThen:
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:
- Walks every registered plugin in topological order, collecting
each one's
Plugin::commands()Vec. - Builds a single top-level clap parser with every plugin command as a subcommand.
- Parses argv. If a plugin command matches, calls that command's
run(&matches). - 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().awaitworks. - Settings are live;
umbral::settings::get()returns the resolved values. - Every other plugin's
on_readyhas fired (commands run after on_ready by design - see the dispatch order incrates/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
--noinputflag that reads from env vars instead, so CI / containers / Ansible can call your command non-interactively.createsuperuseris 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.