Scaffolding (startproject, startapp, startplugin)
The `umbral` global binary creates new projects (`startproject`), minimal plugin stubs (`startapp`), and richer distributable-plugin scaffolds (`startplugin`).
Three names, what each one is
Three Cargo entities share the umbral name. Worth pinning down before the install commands below:
| Name | Kind | Where you see it | What it is |
|---|---|---|---|
umbral | Library crate (framework) | Cargo.toml dep, use umbral::prelude::* | The framework itself - App::builder, ORM, web, plugins. |
umbral-cli | Library + binary crate | Cargo.toml dep + cargo install umbral-cli | The 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). |
umbral | Executable on PATH | After cargo install umbral-cli | The scaffolding tool - umbral startproject / umbral startapp. 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:
# 1. ONCE globally - installs the `umbral` scaffolding binary on PATH:cargo install umbral-cli# 2. Every project - both crates as deps in `Cargo.toml`# (the project that `umbral startproject` generates already lists them):[dependencies]umbral = "..." # the framework libraryumbral-cli = "..." # for `umbral_cli::dispatch(app)` in main.rsThere'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 three things: startproject, startapp, and startplugin. Everything else (serve, migrate, createsuperuser, …) runs against your project's own binary via cargo run -- <command>.
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 --
startproject
umbral startproject myblogcd myblogcargo run -- migrate # apply auth, sessions, and Post migrationscargo run -- serve # boot at http://127.0.0.1:8000What 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.
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 app plugins land here (umbral startapp <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 panicThese 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
| Surface | Where |
|---|---|
Post model with ForeignKey<AuthUser> | struct Post in main.rs |
| Migrations auto-run on boot | auto_migrate() in main.rs |
| First-run data seeding | seed::all() in main.rs → src/seed/credentials.rs |
| Public home route | GET / → views::public::home + home.html |
| JSON API endpoint | GET /api/posts → views::public::api_list_posts |
login_required_html("/login") layer | /dashboard route in the router (main.rs) |
LoggedIn<AuthUser> extractor | views::public::dashboard handler signature |
umbral::transaction | inside dashboard wrapping the ORM fetch |
Shared internal_error 500 helper | src/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::with_config(...)) |
| Custom 404 / 500 templates | .not_found_template("404.html") + .server_error_template("500.html") |
| Tailwind classes (CDN) | templates/base.html |
Flags:
| Flag | Effect |
|---|---|
--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. startapp and startplugin accept 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 app plugins. Run umbral startapp <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.
startapp
A plugin is a self-contained unit that bundles a slice of your app's
models, routes, and logic. startapp scaffolds a new plugin crate at
plugins/<name>/:
umbral startapp postsGenerates a per-concern layout - one file per concern, so a new plugin opens to obvious "what's in which file" structure:
plugins/posts/├── Cargo.toml # umbral dep└── src/ ├── lib.rs # the Plugin impl (name/models/routes/on_ready) ├── models.rs # #[derive(Model)] structs (this app's tables) ├── views.rs # HTTP handlers └── urls.rs # the route table (router()) mapping paths to handlersThe generated lib.rs glues the modules together; routes() returns urls::router() so the route table lives in one place:
pub mod models;pub mod urls;pub mod views; use umbral::plugin::{AppContext, Plugin, PluginError};use umbral::web::Router; #[derive(Debug, Default, Clone)]pub struct PostsPlugin; impl Plugin for PostsPlugin { fn name(&self) -> &'static str { "posts" } fn models(&self) -> Vec<umbral::migrate::ModelMeta> { // Register every model the plugin owns so makemigrations // picks them up. Uncomment + extend once you've defined one // in src/models.rs. // vec![umbral::migrate::ModelMeta::for_::<models::Example>()] Vec::new() } fn routes(&self) -> Router { // Routes live in urls.rs (this app's URL conf). urls::router() } fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> { Ok(()) }}urls.rs is the plugin's route table - it maps paths to views:: handlers:
use umbral::web::{Router, routing::get};use crate::views; pub fn router() -> Router { Router::new().route("/posts/", get(views::index))}views.rs holds the handlers (a sample index ships out of the box), and models.rs is where you declare your first #[derive(Model)] struct.
startapp auto-wires the new crate into your project's Cargo.toml. After scaffolding it adds the path dep under [dependencies] for you:
posts = { path = "plugins/posts" }The insertion is idempotent (a second startapp posts won't duplicate the line) and preserves your existing dep ordering and comments. If the project has no Cargo.toml (you ran startapp outside a project), the scaffold still writes the plugin files and just skips the dep registration.
The one edit you still make yourself is wiring the plugin into your App::builder() chain in src/main.rs - the main.rs shape is yours to control, so silently rewriting it would be more surprise than help:
.plugin(posts::PostsPlugin::default())Plugin names follow the same rules as Cargo crate names - ASCII
alphanumeric, underscore, hyphen, can't start with a digit. The
struct name is pascal_case(name) + "Plugin", so blog-engine
becomes BlogEnginePlugin.
Names umbral refuses to scaffold
startapp (and startplugin below) reject two classes of names up front so you don't get a half-broken plugin you have to delete and recreate:
- A plugin with the same name already exists at
plugins/<name>/. Move it aside or pick a different name. - The name collides with a built-in umbral plugin. Scaffolding refuses these so you can never end up trying to register both
.plugin(my_auth::AuthPlugin)and.plugin(umbral_auth::AuthPlugin)in the same builder chain (the route mounts and migration table names would collide at boot). Reserved names:admin,app,auth,cache,email,openapi,permissions,rest,rls,security,sessions,signals,static,tasks.
Pick a name that names your domain (blog, billing, inventory) rather than the capability the built-in already provides.
startplugin
startapp is the minimal stub. startplugin is the richer version - same destination (plugins/<name>/), but the generated scaffold is shaped for a plugin you intend to publish or share across projects:
umbral startplugin widgetsGenerates:
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 handlerThe 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:
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]pub struct WidgetsItem { pub id: i64, #[umbral(string, max_length = 200)] pub title: String, pub status: WidgetsStatus, // a #[derive(sqlx::Type)] choice enum #[umbral(noedit)] pub published_at: Option<DateTime<Utc>>,}The generated src/handlers.rs shows how to read query params and return JSON:
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}!") })}After scaffolding, the printed next-steps walk you through wiring it into your project: add the path dep in Cargo.toml, register the plugin in App::builder(), then cargo run -- makemigrations && cargo run -- migrate to apply the example model's schema.
When to pick which
| Choosing... | Use |
|---|---|
| A local app plugin for your project | startapp (per-concern lib/models/views/urls layout, auto-wired into your Cargo.toml) |
| A reusable plugin you'll share or publish | startplugin (adds a README + example model with field attributes + real deps for a distributable crate) |
| You're learning the Plugin trait surface | startplugin - the example model + handler show the attributes and patterns you hit on day one |
Same reserved-name list and same already-exists guard for both.
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).