Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 with 13.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 under zero-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, &note.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, &note)?;
    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, &note.title, &note.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, &note.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, &note_of(7)), Ok(()));
        assert_eq!(authorize(8, &note_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:

  • index lists only the signed-in user’s notes and renders a form. store, update and destroy save, change and delete notes; show opens one.
  • ValidatedForm<NoteForm> rejects an empty or overlong title before the handler runs. For an HTMX request it answers with an error fragment, which hx-target="#errors" places under the form; the REST status travels in the X-Rullst-Validation-Status header.
  • After a successful write, HTMX receives an HX-Redirect back to the list, and a plain form post a 303 redirect, so the forms work without JavaScript too.
  • html! escapes the values it interpolates: a note titled Buy <milk> is rendered as Buy &lt;milk&gt;. It returns Markup, so note_form and 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:

RequestResult
GET /notes without signing in303 to /login
Alice: GET /notes/1 (her note)200
Bob: GET /notes/1404
Bob: POST /notes/1/delete404, and the note is kept
POST /notes without the CSRF token403
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 new APP_KEY and the billing settings described in the generated BILLING.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 set RULLST_ENV=production;
  • choose a platform: guided container deployment (cargo rullst deploy writes a Dockerfile, compose.yaml and Caddyfile for a Linux VM; see Deploy to a Linux VM), Kubernetes, or a container from cargo rullst dockerize. Run migrations once per release, before starting the new version.

Where next