| name | rust-crud |
| description | Scaffold a Rust CRUD web application with Axum, SQLite, Askama templates, API key auth, embedded static assets, and Docker deployment. Use when the user wants to build a new Rust web server with CRUD operations. |
Rust CRUD Web App
Overview
Scaffold and build Rust CRUD web applications using Axum + SQLite + Askama templates. The result is a single binary web server with HTML pages, a JSON API, optional API key auth, and Docker deployment.
Project Structure
project-name/
├── src/
│ ├── main.rs # Entry point, starts the server
│ ├── server.rs # Axum routes, middleware, handlers, static asset serving
│ ├── db.rs # SQLite schema, CRUD functions, error types
│ └── auth.rs # Session/cookie auth (when using login-based auth)
├── templates/ # Askama HTML templates
│ ├── base.html # Base layout with blocks (title, content)
│ └── *.html # Pages extend base.html
├── static/ # CSS, fonts, favicons (embedded via rust_embed or served via tower-http)
│ └── styles.css
├── assets/ # Favicons, fonts, images (embedded via rust_embed)
├── .env.example # Environment variable reference
├── Dockerfile # Multi-stage build
└── docker-compose.yml # Compose config with volume for DB persistence
Dependencies (Cargo.toml)
Use these exact crates and features:
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
askama = "0.15"
askama_web = { version = "0.15", features = ["axum-0.8"] }
rusqlite = { version = "0.38", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
nanoid = "0.4.0"
rust-embed = "8"
dotenvy = "0.15"
subtle = "2"
tracing = "0.1"
tracing-subscriber = "0.3"
rand = "0.8"
tower-http = { version = "0.6", features = ["fs"] }
Only add additional crates when the specific app requires them. Do NOT include TUI crates (ratatui, crossterm), CLI crates (clap), or HTTP client crates (reqwest) unless explicitly requested.
tracing + tracing-subscriber — structured logging, always include
rand — needed for session token generation when using session-based auth
tower-http — alternative to rust-embed for serving static files from disk (simpler during development, no recompile on asset changes)
Database Layer (db.rs)
Pattern: single-file module with Arc<Mutex<Connection>> for thread-safe SQLite access.
Structure
use nanoid::nanoid;
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::sync::{Arc, Mutex};
pub type Db = Arc<Mutex<Connection>>;
#[derive(Debug)]
pub enum DbError {
Sqlite(rusqlite::Error),
LockPoisoned,
}
impl fmt::Display for DbError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DbError::Sqlite(e) => write!(f, "Database error: {}", e),
DbError::LockPoisoned => write!(f, "Database lock poisoned"),
}
}
}
impl std::error::Error for DbError {}
impl From<rusqlite::Error> for DbError {
fn from(e: rusqlite::Error) -> Self {
DbError::Sqlite(e)
}
}
Key patterns
- Model struct: derive
Serialize, Deserialize, all fields pub
- ID generation:
nanoid!(10) for short unique IDs
- DB path from env:
std::env::var("APP_DB_PATH").unwrap_or_else(|_| "app.sqlite".to_string())
- init_db(): opens connection, runs
CREATE TABLE IF NOT EXISTS, returns Arc<Mutex<Connection>>
- CRUD functions: standalone functions that take
&Db as first param
create_* — INSERT, return created model with last_insert_rowid()
get_*_by_short_id — SELECT, return Result<Option<Model>, DbError>
get_all_* — SELECT with ORDER BY id DESC
delete_*_by_short_id — DELETE, return Result<bool, DbError> (rows_affected > 0)
update_*_by_short_id — UPDATE then SELECT to return updated model
- Error handling:
QueryReturnedNoRows maps to Ok(None), not an error
Server Layer (server.rs)
Embedded assets with rust_embed
use rust_embed::Embed;
#[derive(Embed)]
#[folder = "assets/"]
struct Assets;
#[derive(Embed)]
#[folder = "static/"]
struct Static;
Serve with handlers that match on file path and return correct MIME types:
fn mime_from_path(path: &str) -> &'static str {
match path.rsplit('.').next().unwrap_or("") {
"css" => "text/css",
"js" => "application/javascript",
"html" => "text/html",
"png" => "image/png",
"ico" => "image/x-icon",
"svg" => "image/svg+xml",
"woff" | "woff2" => "font/woff2",
"ttf" => "font/ttf",
"otf" => "font/otf",
"json" | "webmanifest" => "application/json",
_ => "application/octet-stream",
}
}
App state
#[derive(Clone)]
struct AppState {
db: Db,
server_config: ServerConfig,
}
Add domain-specific fields as needed (e.g., a highlighter, cache, etc).
Askama templates
use askama::Template;
use askama_web::WebTemplate;
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate;
#[derive(Template)]
#[template(path = "item.html")]
struct ItemTemplate {
name: String,
content: String,
}
Render with WebTemplate(MyTemplate { ... }).
Route structure
Two sets of routes: web routes (HTML pages + form submissions) and API routes (JSON).
Web routes:
GET / — index page (template)
GET /admin — admin panel (template)
POST /items — form submission, redirects on success
GET /items/{short_id} — view single item (template)
API routes:
GET /api/items — list all (JSON)
POST /api/items — create (JSON body → 201 + JSON)
GET /api/items/{short_id} — get one (JSON)
PUT /api/items/{short_id} — update (JSON body → JSON)
DELETE /api/items/{short_id} — delete (JSON)
Static asset routes:
GET /assets/{*path} — embedded assets (favicons, fonts, images)
GET /static/{*path} — embedded static files (CSS)
Form deserialization
#[derive(Deserialize)]
struct CreateItemForm {
name: String,
content: String,
}
Use Form(form): Form<CreateItemForm> for HTML forms, Json(body): Json<CreateItem> for API.
Error responses
- Web handlers return
Result<..., (StatusCode, Html<String>)>
- API handlers return
Result<..., (StatusCode, Json<serde_json::Value>)>
- Use
serde_json::json!({"error": "message"}) for API error bodies
Authentication
API key auth middleware
Configurable per-endpoint authentication using an API key in the x-api-key header. Uses constant-time comparison via the subtle crate.
#[derive(Clone)]
struct ServerConfig {
api_key: Option<String>,
auth_endpoints: HashSet<String>,
max_content_size: usize,
}
impl ServerConfig {
fn from_env() -> Self {
let api_key = std::env::var("APP_API_KEY").ok();
let auth_endpoints = match std::env::var("APP_AUTH_ENDPOINTS") {
Ok(val) if val.trim().eq_ignore_ascii_case("none") => HashSet::new(),
Ok(val) => val.split(',').map(|s| s.trim().to_lowercase()).collect(),
Err(_) => ["api_delete", "api_list", "api_update"]
.iter().map(|s| s.to_string()).collect(),
};
let max_content_size = std::env::var("APP_MAX_CONTENT_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(512_000);
ServerConfig { api_key, auth_endpoints, max_content_size }
}
fn requires_auth(&self, name: &str) -> bool {
self.auth_endpoints.contains("all") || self.auth_endpoints.contains(name)
}
}
Auth middleware function
async fn require_api_key(
State(state): State<AppState>,
headers: HeaderMap,
request: Request,
next: Next,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
let server_key = match &state.server_config.api_key {
Some(k) => k,
None => return Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({"error": "No API key configured on server"})),
)),
};
let provided = headers.get("x-api-key").and_then(|v| v.to_str().ok());
match provided {
Some(k) if k.as_bytes().ct_eq(server_key.as_bytes()).into() => {
Ok(next.run(request).await)
}
_ => Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Invalid or missing API key"})),
)),
}
}
Dynamic route building with selective auth
Build routes dynamically based on which endpoints require auth. Authed routes get the middleware layer; open routes don't.
fn build_api_routes(state: &AppState) -> Router<AppState> {
let config = &state.server_config;
let auth_layer = middleware::from_fn_with_state(state.clone(), require_api_key);
let mut authed = Router::new();
let mut open = Router::new();
if config.requires_auth("api_list") {
authed = authed.route("/api/items", get(api_list));
} else {
open = open.route("/api/items", get(api_list));
}
let authed = authed.route_layer(auth_layer);
authed.merge(open)
}
Session/cookie auth (for web-facing apps)
When the app needs a login page instead of API key auth (e.g., personal dashboards), use session-based authentication with a custom Axum extractor. Create a separate auth.rs module.
Sessions table in db.rs:
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL
);
Session DB functions in db.rs:
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> {
let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
conn.execute("INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", params![token, expires_at])?;
Ok(())
}
pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> {
let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
match conn.query_row("SELECT expires_at FROM sessions WHERE token = ?1", params![token], |row| row.get(0)) {
Ok(val) => Ok(Some(val)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(DbError::Sqlite(e)),
}
}
pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> {
let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
Ok(())
}
pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> {
let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
conn.execute("DELETE FROM sessions WHERE expires_at < datetime('now')", [])?;
Ok(())
}
auth.rs module:
use axum::{
extract::{FromRef, FromRequestParts},
http::request::Parts,
response::{IntoResponse, Redirect, Response},
};
use rand::RngCore;
use subtle::ConstantTimeEq;
use crate::AppState;
pub fn verify_password(input: &str, expected: &str) -> bool {
const LEN: usize = 256;
let mut a = [0u8; LEN];
let mut b = [0u8; LEN];
let ib = input.as_bytes();
let eb = expected.as_bytes();
a[..ib.len().min(LEN)].copy_from_slice(&ib[..ib.len().min(LEN)]);
b[..eb.len().min(LEN)].copy_from_slice(&eb[..eb.len().min(LEN)]);
let lengths_match = subtle::Choice::from((ib.len() == eb.len()) as u8);
(lengths_match & a.ct_eq(&b)).into()
}
pub fn generate_session_token() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
pub fn build_session_cookie(token: &str, secure: bool) -> String {
let mut cookie = format!("session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800", token);
if secure { cookie.push_str("; Secure"); }
cookie
}
pub fn clear_session_cookie() -> String {
"session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0".to_string()
}
pub struct AuthSession;
impl<S> FromRequestParts<S> for AuthSession
where
S: Send + Sync,
Arc<AppState>: FromRef<S>,
{
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let state = Arc::<AppState>::from_ref(state);
let token = extract_session_cookie(&parts.headers);
if let Some(token) = token {
if is_valid_session(&state, &token) {
return Ok(AuthSession);
}
}
Err(Redirect::to("/login").into_response())
}
}
fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
let cookie_header = headers.get("cookie")?.to_str().ok()?;
for part in cookie_header.split(';') {
let part = part.trim();
if let Some(val) = part.strip_prefix("session=") {
let val = val.trim().to_string();
if !val.is_empty() { return Some(val); }
}
}
None
}
Usage in handlers — just add _session: auth::AuthSession as a parameter to protect a route:
async fn get_index(_session: auth::AuthSession, State(state): State<Arc<AppState>>) -> Response {
}
Login/logout routes:
AppState for session auth:
pub struct AppState {
pub db: Db,
pub app_password: String,
pub cookie_secure: bool,
}
Environment variables
Prefix all env vars with the app name (e.g., MYAPP_):
| Variable | Purpose | Default |
|---|
APP_API_KEY | API key for auth | None (auth disabled) |
APP_AUTH_ENDPOINTS | Comma-separated endpoint names, "all", or "none" | api_delete,api_list,api_update |
APP_MAX_CONTENT_SIZE | Max request body size in bytes | 512000 |
APP_DB_PATH | SQLite file path | app.sqlite |
APP_PASSWORD | Single password for session auth (web apps) | None |
COOKIE_SECURE | Set true for HTTPS-only cookies | false |
Templates (Askama)
HTML templates live in templates/ and use Askama syntax. Key patterns:
- Link CSS via
/static/styles.css
- Link assets via
/assets/filename
- Include
<meta name="theme-color" content="#121113" />
- Forms POST to web routes (not API routes)
- Use
{{ variable }} for template interpolation
- Use
{{ variable|safe }} for pre-rendered HTML (e.g., syntax highlighted content)
Template inheritance
Use a base.html with block sections. All pages extend it:
templates/base.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}APP_NAME{% endblock %}</title>
<meta name="theme-color" content="#121113" />
<style>
</style>
</head>
<body>
<div class="container">
{% block content %}{% endblock %}
</div>
</body>
</html>
templates/index.html:
{% extends "base.html" %}
{% block title %}Items{% endblock %}
{% block content %}
{% if let Some(error) = error %}
<p class="error">{{ error }}</p>
{% endif %}
{% for item in items %}
<div>{{ item.name }}</div>
{% endfor %}
{% endblock %}
Flash messages via query params
Pass transient error/success messages through redirects using query parameters. No session flash needed.
Query param struct:
#[derive(Deserialize, Default)]
pub struct FlashQuery {
pub error: Option<String>,
}
In handlers — redirect with message:
Redirect::to("/items/add?error=Name+is+required.").into_response()
In receiving handler — extract and pass to template:
async fn get_add(Query(q): Query<FlashQuery>) -> Response {
render(AddTemplate { error: q.error })
}
In template — conditionally render:
{% if let Some(error) = error %}
<p class="error">{{ error }}</p>
{% endif %}
Logging (tracing)
Always initialize tracing in main() before anything else:
tracing_subscriber::fmt::init();
Use throughout the app:
tracing::error!("DB error: {}", e) — unrecoverable failures
tracing::warn!("Non-critical issue: {}", e) — degraded but functional
tracing::info!("Listening on {}", addr) — startup/lifecycle events
main.rs
Minimal — just starts the server:
mod db;
mod server;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3000);
server::run(host, port).await;
}
If the app needs CLI arguments (e.g., --port, --host), add clap and a simple arg struct. But default to env vars and keep main.rs minimal.
Dockerfile
Multi-stage build:
FROM rust:1-slim-bookworm AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
COPY --from=builder /app/target/release/APP_NAME /usr/local/bin/APP_NAME
WORKDIR /data
EXPOSE 3000
CMD ["APP_NAME", "--port", "3000", "--host", "0.0.0.0"]
Replace APP_NAME with the actual binary name.
docker-compose.yml
services:
app:
build: .
ports:
- "3000:3000"
environment:
- APP_API_KEY=${APP_API_KEY:-changeme}
- APP_AUTH_ENDPOINTS=api_delete,api_list
volumes:
- app-data:/data
restart: unless-stopped
volumes:
app-data:
Key: use a named volume to persist the SQLite database across container restarts.
.env.example
Always create one with all configurable env vars and sensible comments.
Checklist
When scaffolding a new app with this pattern:
- Create project structure (
cargo init, add directories)
- Set up
Cargo.toml with dependencies
- Write
db.rs — schema, model struct, CRUD functions
- Write
server.rs — config, state, templates, handlers, auth, routes
- Write
main.rs — minimal entry point
- Create
templates/ with at least an index page
- Create
static/styles.css
- Create
.env.example
- Create
Dockerfile and docker-compose.yml
- Test:
cargo run, verify routes work
What NOT to include
- No external CSS frameworks unless specified
- No ORMs — use raw rusqlite
- No connection pools —
Arc<Mutex<Connection>> is sufficient for SQLite
- No async database drivers — rusqlite is synchronous and that's fine