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

Scaffolding (startproject, startplugin)

The `umbral` global binary creates new projects (`startproject`) and plugin crates (`startplugin`; `startapp` is a deprecated alias).

Three names, what each one is

Three Cargo entities share the umbral name. Worth pinning down before the install commands below:

NameKindWhere you see itWhat it is
umbralLibrary crate (framework)Cargo.toml dep, use umbral::prelude::*The framework itself - App::builder, ORM, web, plugins.
umbral-cliLibrary + binary crateCargo.toml dep + cargo install umbral-cliThe CLI tooling. The library half exposes umbral_cli::dispatch(app) that your main.rs calls so cargo run -- <cmd> works. The binary half is what gets installed globally as umbral (next row).
umbralExecutable on PATHAfter cargo install umbral-cliThe scaffolding tool - umbral startproject / umbral startplugin. Yes, the binary is called umbral but its crate is umbral-cli; same Cargo convention as cargo (crate name) vs cargo-something (subcommand).

So a real user touches umbral in two distinct places:

Code
bash
# 1. ONCE globally - installs the `umbral` scaffolding binary on PATH:
cargo install umbral-cli
Code
toml
# 2. Every project - both crates as deps in `Cargo.toml`
# (the project that `umbral startproject` generates already lists them):
[dependencies]
umbral = "..." # the framework library
umbral-cli = "..." # for `umbral_cli::dispatch(app)` in main.rs

There's no separate cargo install umbral step - the umbral binary comes from the umbral-cli crate. The umbral crate is a library you only ever depend on, never install.

What the global umbral binary does

It does two things: startproject and startplugin (plus startapp, a deprecated alias of startplugin). Everything else (serve, migrate, createsuperuser, …) runs against your project's own binary via cargo run -- <command>.

Info

Two CLIs, one mental model. The global umbral binary scaffolds projects and plugins - it doesn't need a project to run because it creates one. Every other command (serve, migrate, makemigrations, inspectdb, dumpdata, loaddata, createsuperuser, tasks-worker, and any custom subcommand a plugin contributes) runs inside your project via cargo run -- . Your binary hosts those by ending main() with umbral_cli::dispatch(app).await.

Code
text
GLOBAL (cargo install umbral-cli; nothing else needed)
umbral startproject <name>
umbral startplugin <name>
 
PROJECT (inside a project, after umbral startproject)
cargo run -- serve
cargo run -- migrate / makemigrations / showmigrations
cargo run -- inspectdb / dumpdata / loaddata
cargo run -- createsuperuser (from umbral-auth)
cargo run -- tasks-worker (from umbral-tasks)
cargo run -- <your-custom-command> (from any plugin you write)

startproject

Code
bash
umbral startproject myblog
cd myblog
cargo run -- migrate # apply auth, sessions, and Post migrations
cargo run -- serve # boot at http://127.0.0.1:8000

What you get is a complete blog-style demo - not a hello-world skeleton. The first cargo run -- serve boots into a working application that exercises every major umbral surface.

The layout follows the per-concern convention the framework dogfoods in examples/shop: main.rs stays a thin wiring layer that reads like a table of contents, and every subsystem lives behind a mod.rs re-export/orchestrator file. You open day one to a project that already scales past 1000 lines instead of a single ballooning main.rs.

Code
txt
myblog/
├── Cargo.toml # umbral + all built-in plugins + tokio
├── src/
│ ├── main.rs # App builder + route table + boot helpers
│ ├── views/ # HTTP handlers, one file per resource grouping
│ │ ├── mod.rs # re-export layer + shared internal_error helper
│ │ └── public.rs # public/unauth handlers (home, JSON, dashboard)
│ ├── seed/ # first-run data
│ │ ├── mod.rs # seed::all() orchestrator (pins dependency order)
│ │ └── credentials.rs # idempotent dev-superuser seed
│ └── widgets/ # admin dashboard widgets, one file per kind
│ ├── mod.rs # per-kind re-export layer
│ └── cards.rs # one builtin widget so the dashboard isn't empty
├── plugins/ # local plugins land here (umbral startplugin <name>)
│ ├── .gitkeep
│ └── README.md
├── umbral.toml # framework settings
├── .env # working dev env file (do not commit)
├── .env.example # env-variable cheat sheet
├── .gitignore
├── README.md # what's in the project, how to run
└── templates/
├── base.html # Tailwind CDN, nav bar, {% block content %}
├── home.html # home page with post count
├── dashboard.html # login-gated view with post list
├── 404.html # rendered on path miss
└── 500.html # rendered on handler panic
Info

These directories are a recommended convention, not a requirement. The runtime reads main.rs directly and doesn't care whether handlers live in views/, handlers/, or inline. Restructure freely - the scaffold just hands you a shape that already scales.

What the scaffold demonstrates

SurfaceWhere
Post model with ForeignKey<AuthUser>struct Post in main.rs
Migrations auto-run on serve.auto_migrate_on_serve() in main.rs
First-run data seedingseed::all() in main.rssrc/seed/credentials.rs
Public home routeGET /views::public::home + home.html
JSON API endpointGET /api/postsviews::public::api_list_posts
login_required_html("/login") layer/dashboard route in the router (main.rs)
LoggedIn<AuthUser> extractorviews::public::dashboard handler signature
umbral::transactioninside dashboard wrapping the ORM fetch
Shared internal_error 500 helpersrc/views/mod.rs
AuthPlugin + SessionsPlugin.plugin(...) in the builder
RestPlugin with a post resource.plugin(RestPlugin::default().resource(...))
AdminPlugin with a dashboard widget.dashboard_section(widgets::cards::overview_section())
OpenApiPlugin (Swagger UI at /openapi/).plugin(OpenApiPlugin::new())
SecurityPlugin (CSRF + hardening headers, on by default).plugin(SecurityPlugin::new().csrf_exempt(["/api"]))
Custom 404 / 500 templates.not_found_template("404.html") + .server_error_template("500.html")
Tailwind classes (CDN)templates/base.html

Flags:

FlagEffect
--path <dir>Parent directory the new project goes under. Defaults to ..
--local <path>Path-dep every umbral crate against a local repo checkout instead of the public git = "..." URL. For framework contributors iterating without a remote push. startplugin accepts the same flag.

The command refuses to overwrite an existing directory - pick a different name or move the old one aside.

Tour of the scaffold

src/main.rs

The generated main.rs reads like a table of contents. It opens with the per-concern module declarations (mod views; mod seed; mod widgets;), declares the Post model, and then wires the App. Everything else lives in the submodules - main.rs stays a thin wiring layer.

The model declaration is a Post struct with a ForeignKey<AuthUser> author field. The #[derive(Model)] macro generates the Model trait impl, the ORM manager, and the typed column constants (post::ID, post::TITLE, post::PUBLISHED, post::AUTHOR).

The App::builder() chain registers every plugin in order: AuthPlugin, SessionsPlugin, AdminPlugin, RestPlugin, OpenApiPlugin, and SecurityPlugin. SecurityPlugin is mounted by default - it adds CSRF protection plus clickjacking/HSTS hardening headers, with /api exempt so token-authenticated JSON clients can POST without a browser form CSRF cookie. The RestPlugin call chains .resource(ResourceConfig::new("post")), which exposes JSON CRUD at /api/post/; query-string filtering is on by default, so ?published=true and ?title__icontains=rust work out of the box (.disable_filters() turns it off). The route table wires the handlers as views::public::home, views::public::api_list_posts, and views::public::dashboard - "which file owns a handler" is a lookup, not memorisation. On boot, main() runs auto_migrate() then the idempotent seed::all().

src/views/

views/mod.rs is the re-export / discoverability layer: open it and you see the whole web surface plus the shared internal_error helper that every handler bubbles a ? through. views/public.rs holds the public/unauth handlers (home, api_list_posts, dashboard). When auth-gated views land you add pub mod account; to mod.rs and a sibling account.rs - the convention is one file per resource grouping.

src/seed/

seed/mod.rs exposes seed::all(), the orchestrator that pins dependency order: the order steps run in doubles as documentation of which step depends on which. The starter calls just credentials::test_credentials() (src/seed/credentials.rs), which mints a deterministic dev superuser (admin / admin) when no users exist yet. Every step is idempotent - it short-circuits on a non-empty table - so calling all() on a partially-seeded DB tops up only what's missing.

src/widgets/

widgets/mod.rs is the per-kind re-export layer; widgets/cards.rs re-exports one framework builtin (overview_section()) so a fresh /admin/ dashboard isn't empty. Add charts.rs, tables.rs, etc. as your dashboard grows, then mount each section with .dashboard_section(...) in main.rs.

plugins/

An empty home for your local plugins. Run umbral startplugin <name> (below) to create one - it scaffolds the crate under plugins/<name>/ and auto-wires it into your project's Cargo.toml.

templates/

base.html loads Tailwind via CDN so the generated pages look reasonable without a build step. Replace the CDN tag with a compiled stylesheet for production. home.html and dashboard.html demonstrate template inheritance ({% extends "base.html" %}), context variables ({{ post_count }}), and Jinja-style loops.

.env vs umbral.toml

umbral.toml holds configuration that is safe to commit (bind address, dev environment flag, dev secret key). .env holds the same values but is listed in .gitignore - it is the working override file you never check in. Settings::from_env() reads .env automatically, and real process environment variables still win over duplicate keys from the file. In production, set UMBRAL_SECRET_KEY, UMBRAL_DATABASE_URL, and UMBRAL_ENVIRONMENT=prod in your environment directly.

startplugin

A plugin is a self-contained unit that bundles a slice of your app's models, routes, and logic — and it's the only unit umbral scaffolds (there is no separate "app" contract). startplugin creates a new plugin crate at plugins/<name>/:

Code
bash
umbral startplugin widgets

Generates:

Code
txt
plugins/widgets/
├── Cargo.toml # real deps: umbral, serde, sqlx, chrono, async-trait
├── README.md # file-structure tour + wiring instructions
└── src/
├── lib.rs # WidgetsPlugin with the full Plugin impl
├── models.rs # one example #[derive(Model)] with attributes
└── handlers.rs # one example axum handler

The generated src/lib.rs is the full Plugin impl, not the stub - models() returns the example model's meta so the migration engine picks it up, routes() registers the example handler at /<name>/hello, on_ready() is wired and ready for setup work.

The generated src/models.rs shows the field-type attributes most plugin authors hit on day one:

Code
rust
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
pub struct WidgetsItem {
pub id: i64,
 
#[umbral(string, max_length = 200)]
pub title: String,
 
#[umbral(choices)]
pub status: WidgetsStatus, // a #[derive(Choices)] enum
 
#[umbral(noedit)]
pub published_at: Option<DateTime<Utc>>,
}

The generated src/handlers.rs shows how to read query params and return JSON:

Code
rust
pub async fn hello(Query(params): Query<HelloParams>) -> Json<HelloResponse> {
let who = params.name.as_deref().unwrap_or("widgets");
Json(HelloResponse { greeting: format!("Hello, {who}!") })
}

startplugin auto-wires the new crate into your project's Cargo.toml — it adds widgets = { path = "plugins/widgets" } under [dependencies] for you (idempotent: a second run won't duplicate the line, and it preserves your ordering and comments). If the project has no Cargo.toml, it still writes the plugin files and skips the dep step. The one edit left to you is registering the plugin in your App::builder() chain — the printed next-steps spell out the exact .plugin(widgets::WidgetsPlugin::default()) line, then cargo run -- serve (or makemigrations + migrate) applies the example model's schema.

Info
Plugin names follow Cargo crate-name rules — ASCII alphanumeric, underscore, hyphen, can't start with a digit. The struct name is `pascal_case(name) + "Plugin"`, so `blog-engine` becomes `BlogEnginePlugin`.

startapp is a deprecated alias

startapp used to write a thinner plugin skeleton, but everything under plugins/ is a plugin — there is no separate "app" contract — so it now just forwards to startplugin and prints a deprecation note. Use startplugin.

Names umbral refuses to scaffold

Scaffolding rejects two classes of names up front, so you never get a half-broken plugin to delete and recreate:

  1. A plugin with that name already exists at plugins/<name>/. Move it aside or pick a different name.
  2. The name collides with a built-in umbral plugin — you could never register both my_auth::AuthPlugin and umbral_auth::AuthPlugin in one builder (route mounts and migration table names would collide at boot). Reserved names are every built-in: admin, analytics, auth, cache, email, graphql, health, livereload, logs, oauth, openapi, permissions, playground, realtime, rest, rls, security, sessions, signals, storage, tasks, tenants (plus app and static).

Pick a name for your domain (blog, billing, inventory) rather than the capability a built-in already provides.

What's next

Once you have a project, every other command runs through your binary. See Management commands for the full list (serve, migrate, makemigrations, inspectdb, dumpdata, loaddata).