Zero to a complete app
What you will build: Notes, a small web app where people register, sign in and keep private notes. Along the way you will add a model and its migration, CRUD pages with HTMX and form validation, routes that only a note’s owner can open, tests, a security report, a development loop and a production build. Plan for about an hour, most of it waiting for the first build.
This page is one path from an empty folder to a working app. Each step links to the tutorial that explains it in depth.
How this page was recorded. The commands and outputs come from a run on 8 October 2026 with the 13.0 development CLI (
13.0.0-alpha.1), SQLite and Linux. On 29 October 2026 the steps that need no build were repeated with13.0.0-alpha.4: creating the app, the two generators, the security report and the IDOR check printed what this page shows, and the files underzero-to-complete-app/follow that release’s SaaS starter. The test list in step 6 is taken from the alpha.4 sources; build times and sizes are from the first run. Output is shortened with…, and your timestamps will differ.
1. Install Rust and the CLI
Install Rust from rustup.rs, then the v13 prerelease CLI. Cargo never selects a prerelease on its own, so name the exact version, as described in Getting Started:
cargo install cargo-rullst --version =13.0.0-alpha.4 --locked
2. Create the app from the SaaS starter
The SaaS starter already has registration, sign-in, a session middleware, CSRF protection, security headers and Stripe billing that runs offline until you add real keys, so you can focus on your own feature. Create it with SQLite, which needs no database server:
cargo rullst new notes --default --blueprint saas --database sqlite
cd notes
The first build compiles every dependency and takes several minutes (see Build times for measured figures). It ends with:
📦 Bootstrapping Database...
✅ Database tables created successfully.
✅ Initialized a Git repository (.gitignore keeps .env and target/ out).
✨ Project 'notes' created successfully!
Application profile: Zero-Bundle HTMX (html! SSR)
ORM profile: Active Record
Next steps
1 cd notes
2 cargo rullst dev builds, applies migrations and serves with live reload
3 open http://127.0.0.1:3000 your generated welcome page
Prefer a live dashboard? cargo rullst dash · new to Rullst? cargo rullst tour
Commit the generated files now. The security
report in step 7 reads the files Git tracks, and the generated .gitignore
already leaves out .env and the SQLite database:
git add -A
git commit -m "Generated SaaS starter"
3. Add a model with a migration
cargo rullst make:model Note --migration
🛠️ Generating Rullst model: Note...
✨ Model 'Note' successfully created at 'src/models/note.rs'!
✨ Rust migration successfully created at 'src/migrations/m20261008071503_create_notes.rs'!
…
Next steps
→ cargo rullst db:migrate apply the new migration
→ cargo rullst make:controller Note serve the model over HTTP
Give the model its fields in src/models/note.rs. user_id records who wrote
the note; it always comes from the session, never from the form. Keep the
snapshot field the generator added: with it, save() of a loaded note
writes only the columns that changed:
use rullst::db::{FromRow, Orm, RowSnapshot};
#[derive(Debug, Clone, FromRow, Orm)]
#[orm(table = "notes")]
pub struct Note {
pub id: i32,
/// The id of the user who wrote the note; set from the session, never from the form.
pub user_id: i32,
pub title: String,
pub body: String,
// Remembers the loaded values, so `save()` writes only the columns this
// record changed. Build new records with `snapshot: RowSnapshot::default()`.
#[sqlx(skip)]
pub snapshot: RowSnapshot,
}
Add the same columns to the generated migration,
src/migrations/m<timestamp>_create_notes.rs:
use rullst::db::schema::{Schema, Migration};
use rullst::db::async_trait;
pub struct MigrationImpl;
#[async_trait]
impl Migration for MigrationImpl {
fn name(&self) -> &'static str {
"m20261008071503_create_notes"
}
async fn up(&self) -> Result<(), rullst::orm::Error> {
Schema::create("notes", |table| {
table.id();
table.integer("user_id").not_null();
table.string("title").not_null();
table.string("body").not_null();
table.timestamps();
}).await
}
async fn down(&self) -> Result<(), rullst::orm::Error> {
Schema::drop_if_exists("notes").await
}
}
Apply it:
cargo rullst db:migrate
⏳ Running 'cargo run -- db:migrate'...
…
Migrating: m20261008071503_create_notes
Migrated: m20261008071503_create_notes
More on models and schema changes: Active Record CRUD and migrations.
4. CRUD pages with HTMX and validation
Generate the controller. Its form checks use the derive macro of the
validator crate, which the starter already declares at the version Rullst
uses, 0.21:
cargo rullst make:controller Notes
Replace the generated placeholder in src/controllers/notes_controller.rs
with the complete controller:
use rullst::db::RowSnapshot;
use rullst::html::Markup;
use rullst::security::{CspNonce, CsrfToken};
use rullst::server::{Extension, IntoResponse, Path, Redirect, Response, StatusCode};
use rullst::{html, HtmxRequest, HtmxResponse, Validate, ValidatedForm};
use rullst_security::{RbacGuard, UserContext};
use serde::Deserialize;
use crate::models::note::Note;
/// The fields a user may submit. `user_id` is not here: it comes from the session.
#[derive(Debug, Deserialize, Validate)]
pub struct NoteForm {
#[validate(length(min = 1, max = 120, message = "Give the note a title (1 to 120 characters)."))]
pub title: String,
#[validate(length(max = 5000, message = "Keep the note under 5000 characters."))]
pub body: String,
}
/// Lets only the note's owner through. Another user's note answers 404, so
/// its existence is not revealed.
pub fn authorize(user_id: i32, note: &Note) -> Result<(), StatusCode> {
let user = UserContext::new(user_id.to_string(), Vec::new());
RbacGuard::authorize_owner_or_role(&user, ¬e.user_id.to_string(), "admin")
.map_err(|_| StatusCode::NOT_FOUND)
}
async fn find_owned(user_id: i32, id: i32) -> Result<Note, StatusCode> {
let note = Note::find(id)
.await
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?
.ok_or(StatusCode::NOT_FOUND)?;
authorize(user_id, ¬e)?;
Ok(note)
}
/// After a successful write, HTMX gets an `HX-Redirect`; a plain form post a 303.
fn back_to_list(htmx: &HtmxRequest) -> Response {
if htmx.is_htmx {
HtmxResponse::new("").redirect("/notes").into_response()
} else {
Redirect::to("/notes").into_response()
}
}
fn page(nonce: &CspNonce, title: &str, content: Markup) -> Markup {
html! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="/static/rullst.css" />
<script nonce={nonce.as_str()} src="/static/htmx-1.9.12.min.js"></script>
</head>
<body><main class="container">{content}</main></body>
</html>
}
}
fn note_form(action: &str, csrf: &CsrfToken, title: &str, body: &str, button: &str) -> Markup {
html! {
<form method="post" action={action} hx-post={action} hx-target="#errors">
<input type="hidden" name="_token" value={csrf.as_str()} />
<label for="title">"Title"</label>
<input id="title" name="title" value={title} required />
<label for="body">"Note"</label>
<textarea id="body" name="body">{body}</textarea>
<div id="errors" role="alert"></div>
<button type="submit">{button}</button>
</form>
}
}
/// GET /notes: the signed-in user's notes and a form to add one.
pub async fn index(
Extension(user_id): Extension<i32>,
Extension(csrf): Extension<CsrfToken>,
Extension(nonce): Extension<CspNonce>,
) -> Result<Markup, StatusCode> {
let notes = Note::query()
.where_eq("user_id", user_id)
.order_by_desc("id")
.get()
.await
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
let items: Markup = notes
.iter()
.map(|note| {
let href = format!("/notes/{}", note.id);
html! { <li><a href={href}>{note.title.as_str()}</a></li> }
})
.collect();
let content = html! {
<h1>"My notes"</h1>
<ul id="notes">{items}</ul>
<h2>"New note"</h2>
{note_form("/notes", &csrf, "", "", "Add note")}
};
Ok(page(&nonce, "My notes", content))
}
/// POST /notes: validates the form, then saves a note owned by the current user.
pub async fn store(
htmx: HtmxRequest,
Extension(user_id): Extension<i32>,
ValidatedForm(form): ValidatedForm<NoteForm>,
) -> Result<Response, StatusCode> {
let mut note = Note {
id: 0,
user_id,
title: form.title,
body: form.body,
snapshot: RowSnapshot::default(),
};
note.save().await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
Ok(back_to_list(&htmx))
}
/// GET /notes/{id}: one note, with edit and delete forms. Owner only.
pub async fn show(
Path(id): Path<i32>,
Extension(user_id): Extension<i32>,
Extension(csrf): Extension<CsrfToken>,
Extension(nonce): Extension<CspNonce>,
) -> Result<Markup, StatusCode> {
let note = find_owned(user_id, id).await?;
let action = format!("/notes/{id}");
let delete = format!("/notes/{id}/delete");
let content = html! {
<p><a href="/notes">"← All notes"</a></p>
<h1>{note.title.as_str()}</h1>
{note_form(&action, &csrf, ¬e.title, ¬e.body, "Save")}
<form method="post" action={delete.as_str()}>
<input type="hidden" name="_token" value={csrf.as_str()} />
<button type="submit">"Delete"</button>
</form>
};
Ok(page(&nonce, ¬e.title, content))
}
/// POST /notes/{id}: validates and saves changes. Owner only.
pub async fn update(
htmx: HtmxRequest,
Path(id): Path<i32>,
Extension(user_id): Extension<i32>,
ValidatedForm(form): ValidatedForm<NoteForm>,
) -> Result<Response, StatusCode> {
let mut note = find_owned(user_id, id).await?;
note.title = form.title;
note.body = form.body;
note.save().await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
Ok(back_to_list(&htmx))
}
/// POST /notes/{id}/delete: deletes the note. Owner only.
pub async fn destroy(
htmx: HtmxRequest,
Path(id): Path<i32>,
Extension(user_id): Extension<i32>,
) -> Result<Response, StatusCode> {
let note = find_owned(user_id, id).await?;
note.delete().await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
Ok(back_to_list(&htmx))
}
#[cfg(test)]
mod tests {
use super::*;
fn note_of(user_id: i32) -> Note {
Note {
id: 1,
user_id,
title: "Groceries".into(),
body: String::new(),
snapshot: RowSnapshot::default(),
}
}
#[test]
fn only_the_owner_may_open_a_note() {
assert_eq!(authorize(7, ¬e_of(7)), Ok(()));
assert_eq!(authorize(8, ¬e_of(7)), Err(StatusCode::NOT_FOUND));
}
#[test]
fn an_empty_title_is_rejected() {
let form = NoteForm { title: String::new(), body: "text".into() };
assert!(form.validate().is_err());
let form = NoteForm { title: "Groceries".into(), body: String::new() };
assert!(form.validate().is_ok());
}
}
What it does:
indexlists only the signed-in user’s notes and renders a form.store,updateanddestroysave, change and delete notes;showopens one.ValidatedForm<NoteForm>rejects an empty or overlong title before the handler runs. For an HTMX request it answers with an error fragment, whichhx-target="#errors"places under the form; the REST status travels in theX-Rullst-Validation-Statusheader.- After a successful write, HTMX receives an
HX-Redirectback to the list, and a plain form post a303redirect, so the forms work without JavaScript too. html!escapes the values it interpolates: a note titledBuy <milk>is rendered asBuy <milk>. It returnsMarkup, sonote_formand the collected list items are inserted into the page as HTML, and the handlers return the page directly.
Now mount the routes in the router() function of src/main.rs. Add them
before the csrf_middleware layer, so every form post needs the CSRF token
the pages include, and wrap each one in the starter’s auth_middleware:
// Notes: signed-in users only. Each note belongs to the user who wrote it.
.route("/notes", rullst::routing::get(controllers::notes_controller::index)
.post(controllers::notes_controller::store)
.layer(rullst::server::from_fn(middlewares::auth_middleware::auth_middleware)))
// rullst-access: owner — find_owned() calls RbacGuard::authorize_owner_or_role
.route("/notes/{id}", rullst::routing::get(controllers::notes_controller::show)
.post(controllers::notes_controller::update)
.layer(rullst::server::from_fn(middlewares::auth_middleware::auth_middleware)))
// rullst-access: owner — find_owned() calls RbacGuard::authorize_owner_or_role
.route("/notes/{id}/delete", rullst::routing::post(controllers::notes_controller::destroy)
.layer(rullst::server::from_fn(middlewares::auth_middleware::auth_middleware)))
.layer(rullst::server::from_fn(rullst::security::csrf_middleware))
The whole file is in zero-to-complete-app/main.rs.
More on HTMX rendering,
forms and validation and
routing.
5. Sign-in and owner-only routes
Sign-in comes from the starter: /register and /login set an encrypted
session cookie, and auth_middleware turns it into the user id that the
handlers read as Extension<i32>. Anyone else is redirected to /login. The
cookie carries the account’s session epoch: the dashboard’s Sign out
everywhere button (POST /logout/everywhere) increments it, which ends the
session on every device. The starter also has Change password
(/password/change), which keeps only the current browser signed in, and
Forgot your password? (/password/forgot), which emails a single-use link
that expires in 30 minutes (RESET_TOKEN_LIFETIME_MINUTES, from 10 minutes
to 24 hours); see
Tutorial 11.
Being signed in is not enough to open a note, though: /notes/{id} must
check that the note belongs to that user. Otherwise anyone could read another
user’s note by changing the number in the URL (an IDOR). find_owned loads
the note and calls RbacGuard::authorize_owner_or_role; another user’s note
answers 404, so it does not even reveal that the note exists.
The // rullst-access: owner — reason comment above each route with an {id}
records that decision. cargo rullst audit --idor and the security report in
step 7 fail when a route with a path parameter has no such comment, or when a
non-public classification has no matching guard in the code.
With two accounts, Alice and Bob, the recorded run observed:
| Request | Result |
|---|---|
GET /notes without signing in | 303 to /login |
Alice: GET /notes/1 (her note) | 200 |
Bob: GET /notes/1 | 404 |
Bob: POST /notes/1/delete | 404, and the note is kept |
POST /notes without the CSRF token | 403 |
| Alice: create with an empty title (HTMX) | 200 with X-Rullst-Validation-Status: 422 and the message Give the note a title (1 to 120 characters). |
More on authentication and ownership, RBAC and IDOR.
6. Run the tests
Every new project ships src/security_tests.rs. Its tests
build the app from router(), wrap it in the baseline that Server adds in
production, and check offline that responses carry the security headers, that
a write is refused without the CSRF token and accepted with it, and that the
WAF refuses an injection probe but accepts ordinary prose. The SaaS starter
also checks that repeated sign-in attempts are rate limited. The tests request
/health, /logout and /login; your notes routes sit behind the same
baseline because they are part of router(). The starter’s sign-in
controller and its Nexus access check have unit tests of their own.
The notes controller adds two unit tests: one proves that only the owner
passes authorize, the other that an empty title fails validation. None of
these tests needs a database or the network:
cargo test
running 9 tests
test controllers::auth_controller::tests::length_limits_count_what_the_form_counts ... ok
test controllers::auth_controller::tests::registration_timestamps_are_current_utc_instants ... ok
test controllers::notes_controller::tests::only_the_owner_may_open_a_note ... ok
test controllers::notes_controller::tests::an_empty_title_is_rejected ... ok
test middlewares::nexus_access::tests::a_request_without_a_session_is_sent_to_sign_in ... ok
test security_tests::production_responses_carry_the_security_headers ... ok
test security_tests::the_waf_refuses_injection_and_accepts_prose ... ok
test security_tests::csrf_refuses_a_write_without_the_token ... ok
test security_tests::repeated_sign_in_attempts_are_rate_limited ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in …
The order of the lines can differ from run to run.
7. Check the app with the security report
cargo rullst audit --report
Rullst security report (OWASP ASVS 5.0.0 Level 1 mapping)
Evidence for a reviewer; it does not replace a manual review or a penetration test.
Security checks
✓ Security headers and CSP — no findings
✓ CSRF — no findings
✓ Session and auth cookies — no findings
✓ Rate limiting on authentication routes — no findings
✓ Committed secrets — no findings
✓ Vulnerable dependencies — no findings
✓ IDOR / BOLA route classification — no findings
Personal data
! Inventory — 2 field(s) listed, 2 need review (name heuristic)
Accessibility checks
✓ Images have a text alternative — no findings
✓ Form controls have a label — no findings
✓ Pages declare their language — no findings
NOT EVALUATED: 63 of 70 ASVS Level 1 requirements are outside these static checks.
Report written to SECURITY_REPORT.md
SECURITY_REPORT.md lists the two fields to review: the email columns of
the starter’s User and BillingCustomer models. The vulnerable-dependency
check needs cargo-audit installed. To see the IDOR check work, delete one
rullst-access comment and run cargo rullst audit --idor: it fails and
names the route. See the security report guide.
8. Develop with live reload
cargo rullst dev
Tip: run `cargo rullst dash` for live requests/s, latency, errors and database metrics.
Building the application...
Running initial db:migrate...
Nothing to migrate.
📊 Rullst Studio running on http://127.0.0.1:5555
🚀 SaaS server starting on port 3000...
…
Rullst framework serving on http://127.0.0.1:3000
…
Auto-reload: watching source, assets and configuration; successful builds restart the application.
Open http://127.0.0.1:3000/register, create an account, then go to
http://127.0.0.1:3000/notes. Saving a source file rebuilds and restarts the
app; a failed build keeps the previous one running. cargo rullst dash runs
the same loop inside a terminal dashboard with live requests, latency, errors
and database metrics. When one request repeats the same ORM
operation three or more times, the dashboard adds a POSSIBLE N+1 QUERIES
panel with the route, the operation and the count; see the
N+1 query warning. Stop either with
Ctrl+C. See
supervised auto-reload and the
dash reference.
9. Build for production and deploy
cargo rullst build
🚀 Starting Rullst production build pipeline (Release Mode: true)...
⚙️ Executing cargo build --release...
Finished `release` profile [optimized] target(s) in 5m 48s
📦 Pre-compressing static assets in static/ directory...
…
✨ Pre-compression finished: processed 2 files, generated 2 .br files and 2 .zst files.
🎉 Rullst production build completed successfully!
The release binary is target/release/notes (15.3 MiB in the recorded run).
Cargo builds it with the [profile.release] that
cargo rullst new wrote into Cargo.toml: thin LTO, one codegen unit and
stripped symbols. panic stays unwind, so one panicking handler cannot stop
the server.
The build also writes Brotli and Zstandard copies of the files in static/,
which the server prefers; run it again after changing an asset. The
/static mount also sets cache headers. A file name with a content
hash, such as app.3f9a2c1b.css, is cached for a year as immutable; other
files, such as the starter’s rullst.css, get Cache-Control: no-cache and
are revalidated with their ETag. Started with the development .env, the
binary answered 200 on /health, redirected /notes to /login, and served
/static/rullst.css Brotli-compressed with no-cache and an ETag.
Measure the build with cargo rullst green (Experimental).
On its own, cargo rullst green --path /health starts the release binary with
RULLST_ENV=production, which the SaaS starter refuses until the production
billing settings exist:
error: App could not be started
What happened the app exited during start-up (exit status: 1).
Last stderr lines:
Error: BillingConfigError("production billing requires real credentials, a strong webhook secret, an HTTPS redirect URL, and an explicit plan allowlist")
Until then, either pass --env staging, which the starter accepts with its
mock billing credentials, or start ./target/release/notes with the
development .env and measure it from a second terminal:
cargo rullst green --url http://127.0.0.1:3000 --path /health --bench
The report lists what it measured, each with its method: the binary size of
the running executable, the idle resident memory, the number of third-party
packages in Cargo.lock and, because of --bench, the app’s CPU time during
a 10-second loopback load and the CPU time per request. The container image
size is NOT MEASURED until you name an image with --image. The figures
describe one run on one machine, with the load generator on the same machine;
compare runs only on the same setup. green measures no energy: a carbon figure
appears only as an SCI estimate from your own --cpu-watts and
--grid-intensity. See efficiency figures for an app.
Before it serves real users:
- create a production environment file with
RULLST_ENV=production, a newAPP_KEYand the billing settings described in the generatedBILLING.md; never reuse the development.env; - check that file with
cargo rullst deploy:doctor --env-file .env.production. Run against the development.env, it fails with[FAIL] environment_target, because that file does not setRULLST_ENV=production; - choose a platform: guided container deployment
(
cargo rullst deploywrites aDockerfile,compose.yamlandCaddyfilefor a Linux VM; see Deploy to a Linux VM), Kubernetes, or a container fromcargo rullst dockerize. Run migrations once per release, before starting the new version.
Where next
- Read the generated
AGENTS.mdbefore asking an AI assistant to change the app, and see the CLI reference for every command. - Add background jobs, email or real billing. Work that can wait, such as reports and exports, can be deferred to an allowed time window.
- Before launch, read the security architecture and check each crate’s maturity tier.