A starter application for Rainier, laid out the way MVC applications usually are. Clone it, rename it, and build.
git clone https://github.com/safewords/rainier-sample-project.git my-app
cd my-app
cp .env.example .env
cargo run -- app:seed # a demo user and a few posts
cargo run -- serve # http://127.0.0.1:8000It runs against SQLite in memory out of the box, so there is nothing to install
and nothing to configure. Point DATABASE_URL at a file, MySQL or Postgres when
you want the data to survive — nothing else in the app changes.
Everything is wired and working: models, repositories, a router with groups and named routes, request contracts, a token guard, a policy, an event, a queued job, mailables, notifications with an in-app bell menu, relationships with eager loading, broadcasting with channel authorisation, a WebSocket chat room, server-rendered views, and a console command. Delete what you do not need.
src/
main.rs — the console entry point
bootstrap.rs — assembles and boots the app
config/ — one module per concern
app/
models/ — User, Post, Tag, and their relationships
http/
kernel.rs — global middleware, and the groups
controllers/
middleware/ — an X-Request-Id example
requests/ — request contracts
repositories/ — where the domain's queries get names
providers/ — service registration
jobs/
mail/
notifications/ — a message to a recipient, over their channels
policies/
console/commands/
database/
migrations/ — one module per migration
seeders.rs
routes/
channels.rs — who may subscribe to what
ws.rs — WebSocket endpoints, on the same port
web.rs
api.rs
console.rs
resources/views/ — templates
tests/feature.rs — end-to-end tests
Dockerfile — a two-stage production image
.github/workflows/ — fmt, clippy, tests, docker
Two entries may be unfamiliar.
repositories/ is where a query gets a name — published_page rather than a
Criteria each controller assembles. And migrations/ is a directory of
modules listed in mod.rs rather than files discovered by timestamp, because
Rust does not autoload and a list you can read beats a scan you cannot.
cargo run -- list # everything available
cargo run -- serve --port=3000
cargo run -- route:list # the route table, with middleware
cargo run -- route:list --json
cargo run -- migrate --pretend # what would run
cargo run -- migrate:rollback # undo the last batch
cargo run -- queue:work --once # drain the queue
cargo run -- app:seed --fresh # your own command
cargo run -- key:generate # an APP_KEY to paste into .envLog in as the seeded user (ada@example.com / correct-horse) to get a token:
TOKEN=$(curl -s localhost:8000/login \
-H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"correct-horse"}' | jq -r .token)
curl localhost:8000/api/posts
curl localhost:8000/api/me -H "authorization: Bearer $TOKEN"
curl -X POST localhost:8000/api/posts \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"title":"Hello","body":"A body long enough to clear the minimum."}'Publishing a post raises an event; a listener queues a job; the job sends the author a notification, which goes out over mail and the database channel:
curl -X POST localhost:8000/api/posts/hello/publish -H "authorization: Bearer $TOKEN"
curl localhost:8000/api/notifications -H "authorization: Bearer $TOKEN"The bell menu fills once a worker has run the job. Out of the box the queue is
MemoryQueue, which lives inside one process — so cargo run -- queue:work
in a second terminal will not see it. Switch the driver to DatabaseQueue in
app/providers/app_provider.rs (and merge its migrations) and it will; the
feature tests drive a worker directly and assert on both channels.
There is a WebSocket room on the same port, needing no second process:
# any WebSocket client; the token is the one from /login
websocat -H "authorization: Bearer $TOKEN" ws://localhost:8000/ws/rooms/lobbyOpen two and they hear each other. authorize runs before the handshake, so a
connection with no token is refused with a 403 rather than opened and then
closed.
An event is a fact with no recipient; a notification is a message to a named
one; a broadcast is a push to whoever is connected; a socket is a conversation. src/app/notifications/mod.rs
has the table that tells them apart.
The index returns each post with its author and tags in three queries,
whatever the page size — Post::author() is a belongs_to, Post::tags() a
belongs_to_many through post_tag. Publishing also broadcasts
post.published on the public posts channel; src/routes/channels.rs says
who may subscribe to the private ones.
Neither is a Rainier decision — both fall out of the language, and both are the things a developer arriving from a dynamic language trips over first.
-
Nothing is autoloaded. Every directory has a
mod.rslisting its files. Adding a controller means adding one line tosrc/app/http/controllers/mod.rs. The compiler tells you when you forget. -
Nothing is discovered by name. A provider is registered in
bootstrap.rs, a route inroutes/, a job in the provider'sJobRegistry, a listener inEventServiceProvider. The wiring is explicit — more typing, but no "why isn't my listener firing?".
| To add a… | Write it in | And register it in |
|---|---|---|
| model | app/models/ |
app/models/mod.rs |
| controller | app/http/controllers/ |
app/http/controllers/mod.rs + a route |
| route | routes/web.rs or routes/api.rs |
— |
| request contract | app/http/requests/ |
app/http/requests/mod.rs |
| middleware | app/http/middleware/ |
app/http/kernel.rs |
| job | app/jobs/ |
the JobRegistry in app/providers/app_provider.rs |
| mailable | app/mail/ |
— (constructed where it is sent) |
| notification | app/notifications/ |
— (constructed where it is sent) |
| relationship | the model, as a pub fn |
— (nothing to register) |
| broadcast channel rule | routes/channels.rs |
— (the list is the registration) |
| WebSocket endpoint | routes/ws.rs |
— (the list is the registration) |
| policy | app/policies/ |
— (called from a controller) |
| command | app/console/commands/ |
routes/console.rs |
| migration | database/migrations/ |
database/migrations/mod.rs — append to all() |
| service | anywhere | app/providers/app_provider.rs |
| config section | config/ |
one line in config/mod.rs |
| cache or session driver | — | .env, plus the cargo feature |
The framework's optional pieces, each wired into this application behind a cargo feature of the same name. A default build compiles none of them; the tables they use are created in every build, so turning one on is never a schema change.
cargo run --features add-ins -- serve # all of them
cargo run --features accounts,two-factor -- serve # or some
cargo test --features add-ins| Feature | What it adds | Configure with |
|---|---|---|
accounts |
registration, login with lockout, logout, password reset, email verification, password confirmation and change — under /account, on a session, with CSRF; GET /account for verified members; /admin/impersonate/* for ADMIN_EMAILS |
APP_URL, ADMIN_EMAILS |
two-factor |
TOTP enrolment, recovery codes, and the challenge after a right password | — |
passkeys |
registering a passkey, and signing in with one | APP_URL (the origin passkeys are bound to) |
sso |
/auth/{provider}/redirect and /callback — GitHub, and any OpenID Connect issuer |
GITHUB_CLIENT_*, OIDC_* |
billing-stripe |
POST /billing/checkout, POST /billing/portal, GET /billing/pro for subscribers, and POST /stripe/webhook keeping a local copy of each subscription |
STRIPE_SECRET, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE |
oauth-server |
/oauth/authorize (consent), /oauth/token, /oauth/revoke, and GET /api/v1/me for tokens with the account scope; cargo run -- oauth:client registers clients |
OAUTH_PRIVATE_KEY |
telemetry |
a span for every request, query, cache call, job and mail, and queue metrics | TELEMETRY_ENABLED, OTEL_*, WORKER_METRICS_ADDRESS |
captcha |
a Turnstile check in front of registration | CAPTCHA_SECRET, CAPTCHA_HOSTNAME |
notify-discord |
published posts announced in a Discord channel | DISCORD_WEBHOOK_URL |
compat-php |
running beside a PHP application: its signed-in sessions and CSRF tokens accepted here | PHP_APP_KEY, PHP_SESSION_COOKIE |
add-ins is every one except compat-php, which is for one situation.
Everything after accounts builds on it — the browser session and its web
guard are what checkout, "sign in with GitHub", the consent screen and
impersonation all hang off.
Where it lives:
| assembly, in one place | app/providers/addin_provider.rs |
| the routes | routes/addins.rs |
| what is read from the environment | config/services.rs |
| the add-ins' tables, and the ports over them | app/models/account_state.rs, app/models/oauth.rs, app/repositories/account_store.rs, oauth_store.rs, subscription_repository.rs |
| the account view of a user | app/models/member.rs — a User plus what the add-ins know about them |
| their tests | tests/addins.rs — a cookie jar, and the providers answering from a fake |
A front end posting to the account routes reads the XSRF-TOKEN cookie
(GET /account/csrf hands one out) and sends it back as X-XSRF-TOKEN:
curl -c jar -b jar localhost:8000/account/csrf
curl -c jar -b jar -H "x-xsrf-token: $(awk '/XSRF-TOKEN/ {print $7}' jar)" \
-H 'accept: application/json' -H 'content-type: application/json' \
-d '{"name":"Ada","email":"ada@example.com","password":"correct horse battery","password_confirmation":"correct horse battery"}' \
localhost:8000/account/registerA Docker image carries only the add-ins its build environment names, as
RAINIER_ADDINS=accounts,billing-stripe in .env.build or
docker build --build-arg ADDINS=accounts,billing-stripe . — the sizer never
infers one from the source. See the Dockerfile.
cargo testtests/feature.rs boots the real application and drives the real kernel —
real routes, real middleware, real database, real migrations. Only the mail
transport is a double, so a test can assert on what was sent.
TestApp does the driving, so a test is three lines:
let app = App::boot().await;
app.send(app.get("/health")).await.assert_ok().assert_json_path("status", "ok");Each test gets its own application, and TestApp scopes the facades to the
thread it runs on, so they no longer resolve out of each other's containers.
The boot itself is still serialised: the bootstrap installs its application
globally before the providers run, because a provider legitimately reaches for
a facade while it is being registered.
GET /visits is the smallest useful example: a counter in the session, a
flashed message that survives exactly one further request, and a CSRF token.
curl -c jar -b jar localhost:8000/visits
curl -c jar -b jar localhost:8000/visits # visits: 1, and the flash arrivesThe web group includes session; the api group deliberately does not, so
an API call authenticating with a token does not allocate a session row and a
cookie it will never use. A route outside web has request.session() == None.
Encryption is wired from APP_KEY:
let sealed = Crypt::instance().encrypt("a card number")?;
let signed = Crypt::instance().sign("unsubscribe-42")?; // readable, not editableGenerate a key with cargo run -- key:generate. Without one a random key is
minted per boot, which works and silently invalidates everything the last boot
encrypted — so it warns.
Vite, the way a PHP framework wires it — and entirely optional: cargo run
works before npm ever has. resources/js and resources/css are source; the layout names
them with a directive:
@vite(['resources/css/app.css', 'resources/js/app.js'])npm install
npm run dev # hot reload: writes public/hot, @vite points at the dev server
npm run build # compiles public/build + manifest.json, @vite emits hashed URLsWith neither running, @vite renders an HTML comment saying which command to
run and the page arrives unstyled rather than down — the layout keeps a small
inline fallback so it stays presentable.
The pieces, each small enough to read: vite.config.js (the entries, the
manifest, and a ~20-line inline plugin that maintains public/hot),
asset_controller (serves public/build under /build/{path*},
traversal-safe, immutable-cached — Rainier is the web server, so the built
files need a route), and the Dockerfile's assets stage (the image compiles
its own bundle; nothing built locally leaks in). The framework side is
documented in the framework's docs/vite.md.
Every subsystem a deployment does not use is a cargo feature it does not enable — the Redis and Memcached clients, each mail sender, SQS, Kafka, S3, the bcrypt driver, the JWT stack, the outbound HTTP transport. The default build carries none of them, and a fresh clone still runs: SQLite in memory, inline jobs, logged mail, local files.
Dead-code elimination will not do this for you. The driver matches in
bootstrap.rs and the providers are deliberately exhaustive — that is what
makes a misconfigured deployment fail loudly — so every compiled driver is
referenced, and the linker keeps it. Features are the sizing mechanism.
And cargo cannot flip them on by itself: features are resolved before
anything compiles, they are additive-only, and a build script cannot add one.
"The compiler sees MAIL_DRIVER=smtp and enables mail-smtp" is not a thing
cargo can do. What it can do is be told — so the feature list is computed
rather than hand-maintained, by the framework's own tool:
cargo install cargo-rainier --git https://github.com/safewords/rainier-framework # once
cargo rainier features # what .env implies, with reasons
cargo rainier features --env .env.build
cargo rainier features --env .env.build --list # bare list, for scripts
cargo rainier features --check # CI: fail on a selection nothing forwards
cargo rainier build --env .env.build --releaseAn environment file is required — an explicit --env, or .env. There
is deliberately no fallback to .env.example: sizing a build from the
example's defaults would shape the binary like the documentation rather than
the deployment, silently. Preview against the defaults with
--env .env.example when that is what you mean.
The logic is the framework's rainier-features crate — the driver→feature
mapping is knowledge about Rainier, and its tests there walk every driver
enum so a new driver learns its feature in the same commit that adds it.
This application deliberately carries no tool of its own: the Dockerfile
installs cargo-rainier pinned to the same framework revision the lockfile
pins, so the mapping an image is sized with is the one the code compiles
against. It reads the two honest
sources — the deployment's environment file for every runtime driver
selection, and the source tree for the compile-time choices (Jwt, the
Http facade) — and emits the minimal
--no-default-features --features "…" invocation:
# from .env.production
# redis CACHE_DRIVER=redis
# mail-smtp MAIL_DRIVER=smtp
cargo build --release --no-default-features --features "mail-smtp,redis"
A selection nothing forwards — CACHE_DRIVER=dynamodb, which this
application never wired — is an error rather than a silently smaller list,
and --check turns it into a failing CI step. When the framework grows a
driver, the compiler already points at every match arm that must learn it —
and rainier-features' own tests point at the mapping table, in the same
repository as the driver.
- Database. Set
DATABASE_URL. SQLite in memory is wiped on exit. - Queue.
QUEUE_CONNECTION=syncruns jobs inline, so a failed job fails the request that dispatched it. Switch todatabaseand runcargo run -- queue:work;bulkis a second connection on the same driver, drained by its own workers. The connections are declared inconfig/queue.rs. - Mail.
MAIL_DRIVER=logwrites to the log.filewrites.emlfiles you can open in a browser. - Sessions.
SESSION_DRIVER=memoryis per-process. Pick one of:database(mergeDatabaseSessionStore::migrations()intodatabase/migrations/mod.rs; never evicts),cache(one of the declared cache stores, expires itself, can evict), orcookie(no server state, and no way to revoke a session). SetSESSION_SECURE=truewhichever you choose. - Cache. Set
REDIS_URLand build with--features redis; pointCACHE_STOREat thesharedstore that declares.SESSION_REDIS_URLdeclares a second store for sessions, which want the opposite eviction policy from a cache. The stores are declared inconfig/cache.rs.
CACHE_DRIVER,QUEUE_DRIVERandSTORAGE_DRIVERare not variables this application reads. Each named a single backend, and each configuration section declares several. Keep all three out of.env; they survive in exactly one place,.env.build, which sizes the Docker image and never reaches the running container.They do not fail the same way, which is worth knowing when you are cleaning up an old
.env:
Variable In .envQUEUE_DRIVERboot failure — Rainier refuses it beside the queuessectionCACHE_DRIVERboot failure, refused by config/cache.rsitselfSTORAGE_DRIVERread by nothing at runtime; harmless, and does nothing
CACHE_DRIVERis the interesting one. Rainier's own refusal lives on the path that builds the cache, andbootstrap.rsskips that path — it hands over a built manager withRainier::with_cacheso sessions, locks and rate limits share one store. That suppressed the guard, and a suppressed guard here meansCACHE_DRIVER=rediswould be silently ignored and leave you on an in-process cache.config/cache.rsre-states the refusal for that reason.
- Keys. Set
APP_KEY. Rotate by moving the old one intoAPP_PREVIOUS_KEYSand putting a new one inAPP_KEY. - Debug.
APP_DEBUG=false— with it on, internal error messages reach the client, and those routinely contain a connection string or a query. - CORS.
config/cors.rsnameshttps://example.com, which is not your front end. Replace the list or append to it withCORS_ALLOWED_ORIGINS— an origin that is not on it cannot call the API from a browser at all. There is no*available here and that is the section's whole subject: the policy allows credentials, no browser accepts credentials besideAccess-Control-Allow-Origin: *, and so allowing everyone is not the lax end of this setting but a policy no browser client can authenticate against.
The image is sized to its deployment: the builder computes the feature
set from a selections-only env file and builds with it. The file is required
— a build without one fails at its COPY rather than shipping a
documentation-shaped binary — and it must hold driver selections only,
never secrets, because it travels into builder layers and the build cache.
Secrets keep arriving at docker run as -e.
printf 'CACHE_DRIVER=redis
MAIL_DRIVER=smtp
' > .env.build
docker build -t rainier-sample .
docker build --build-arg ENV_FILE=.env.staging.build -t rainier-sample:staging .
docker run --rm -p 8000:8000 \
-e APP_KEY="base64:$(openssl rand -base64 32)" \
-e DATABASE_URL="sqlite:///data/app.sqlite?mode=rwc" \
-v rainier-data:/data \
rainier-sampleTwo stages: a builder with the toolchain, and a Debian slim runtime holding the
binary, the templates and nothing else. It runs as a non-root user and
HEALTHCHECK curls /health.
Note the volume. DATABASE_URL defaults to sqlite::memory:, which is wiped
when the container stops — fine for a smoke test and wrong for anything else.
Point it at a mounted path or a real server.
.dockerignore excludes .cargo/config.toml, which is the local development
override pointing at a sibling checkout of the framework. Git ignores it;
Docker has to be told separately, and a build with it present cannot resolve
the dependency.
.github/workflows/ci.yml runs on every push and pull request, and weekly —
the framework is a git dependency with no version to pin against, so main
moving changes this build with no commit here to trigger it.
The fmt and clippy gates also run as a committed pre-commit hook — enable it
once per clone with git config core.hooksPath .githooks. Bypass once with
--no-verify, or skip only the lint with SKIP_CLIPPY=1 git commit ….
| Job | Checks |
|---|---|
check |
cargo fmt (self-healing on main), cargo clippy -D warnings |
test |
the suite under each of the four feature combinations |
migrations |
migrate → rollback → migrate, against a real SQLite file |
routes |
route:list, which compiles the router and builds every middleware |
docker |
builds the image, starts it, and waits for /health |
The last three assert behaviour rather than compilation. migrations requires
the rollback to fail, because 0005_normalise_emails is deliberately
irreversible and a deploy script chaining one has to stop. docker runs the
image because building one and running one are different claims, and the
second is the one that breaks.