A starter project that pairs a Rust API (Axum + sqlx + SQLite) with a Svelte 5 SPA. It ships with invite-gated registration (email/password + OAuth from Google, GitHub, Apple, Microsoft), per-user roles, JWT access tokens, rotating refresh tokens, a small example CRUD feature ("notes"), and the tooling to go from clone to running app in a few commands.
git remote set-url origin ….backend/Cargo.toml → name = "<your-app>"frontend/package.json → "name": "<your-app>"cp .env.example .env
just gen-jwt-keys >> .env # appends RSA keys for JWT signing
# open .env and:
# - set INITIAL_INVITE_CODE to a long random string
# - fill in the OAuth client ids/secrets you actually want
just migrate
just dev
INITIAL_INVITE_CODE, then either set an email + password or pick a
configured provider. The first signup with that code becomes the admin;
the code is single-use.Once you have an admin, generate per-person invite codes directly in SQLite (see "Issuing more invites" below) or build an admin UI.
Only the providers you fill in are exposed; the rest return 503 at runtime, so you don't have to set up all four.
| Layer | Choice |
|---|---|
| Backend | Rust 2024, Axum 0.8, Tokio |
| Database | SQLite via sqlx 0.8 (compile-time checked queries, WAL mode) |
| Auth | OAuth 2.0 / OIDC → RS256 JWT access + rotating refresh |
| Frontend | Svelte 5 (runes), Vite 6, TypeScript |
| Routing (FE) | Tiny built-in hash router — swap for SvelteKit if you outgrow |
| Build (prod) | Vite builds into backend/static/; the Rust binary serves it |
| DX | just, Docker Compose, pre-commit, GitHub Actions CI |
backend/ Rust crate. Axum HTTP server + sqlx + migrations.
src/auth/ OAuth, JWT, refresh-token session, AuthUser extractor.
src/notes/ Example user-scoped CRUD module — clone for new features.
migrations/ sqlx migration .sql files (sequentially numbered).
.sqlx/ Offline query cache. Run `just prepare` after query edits.
tests/ Integration tests against a temp SQLite file.
frontend/ Svelte 5 SPA. Built into backend/static for prod.
src/lib/ api fetcher, auth store, hash router.
src/routes/ Home, Login, Notes pages.
justfile The source of truth for dev commands. Run `just` to list.
docker-compose.yml `docker compose up` for one-command dev.
.github/workflows/ci.yml Type-check, lint, fmt, test, build on every push.
just # list all targets
just dev # backend (:3000) and frontend (:5173) concurrently
just test # cargo test + vitest
just check # fmt + clippy + svelte-check + eslint + prettier
just fmt # auto-format everything
just migrate # apply pending sqlx migrations
just migrate-add add_widgets # create a new migration
just prepare # refresh .sqlx/ offline cache after editing queries
just build # production build → backend/target/release/<binary>
Registration is invite-gated. Login flows authenticate existing users only.
Signup — POST /api/auth/signup/invite/check validates the code (returning
any bound email + role), then either POST /api/auth/signup/password for
email + password, or GET /api/auth/<provider>/signup/start?code=… to attach
an OAuth identity. Either path consumes the invite atomically.
Login — existing accounts use POST /api/auth/login (password) or
GET /api/auth/<provider>/start (OAuth). The OAuth callback refuses to
auto-create users; if no identity matches, it redirects back to /signup with
an error.
Session — login/signup mint an RS256 access JWT (15 min, in memory on the
client) and a rotating refresh token in an HttpOnly cookie scoped to
/api/auth. The SPA's lib/api.ts retries once after a 401 by hitting
/api/auth/refresh. Refresh-token reuse is detected: presenting the same
cookie twice fails — the first call rotated it.
There's no admin UI; create invites with a single SQL statement. From the backend directory:
sqlite3 ../data/app.db \
"INSERT INTO invite_codes (code, email, role) VALUES ('SOMETHING-RANDOM', '[email protected]', 'user');"
email is optional. Omitting it lets anyone with the code register, with any
email. role defaults to 'user'; use 'admin' to grant admin.
Every user has a role (default 'user'). The role is included in the JWT
claims and in /api/auth/me, so server middleware can check it cheaply. There
are currently no admin-only endpoints in the template; add AuthUser-based
checks in handlers as you need them.
Mirror backend/src/notes/:
just migrate-add add_widgets → fill in the up.sql.just migrate, then just prepare so the offline cache picks it up.backend/src/widgets/{mod.rs, repo.rs} (use notes/ as a template).widgets::router() in backend/src/routes.rs.frontend/src/routes/Widgets.svelte and a route
match in App.svelte.The repo ships a multi-stage Dockerfile that builds the SPA, compiles the
backend, and packages both into one ~125 MB debian:bookworm-slim image.
The container runs as a non-root user, persists SQLite under /data, and
includes a HEALTHCHECK against /api/health.
just docker-build # build full-stack-template:latest for your host arch
just docker-run # run it on :3000, mounting the `app-data` named volume at /data
The image fails fast at boot unless these are provided at runtime:
COOKIE_SECRET — 32+ bytes (openssl rand -hex 32)JWT_PRIVATE_KEY_PEM, JWT_PUBLIC_KEY_PEM — RSA keypair from just gen-jwt-keysPUBLIC_BASE_URL — the origin the SPA reaches the API on (e.g. https://yourapp.com)*_CLIENT_ID / *_CLIENT_SECRET pairs you actually useINITIAL_INVITE_CODE — only on first boot, then unsetThe Dockerfile bakes sensible defaults for DATABASE_URL (sqlite under /data),
BIND_ADDR (0.0.0.0:3000), STATIC_DIR, and RUST_LOG; override only if needed.
The just docker-run recipe mounts your local .env as a file at /app/.env
inside the container (not docker --env-file). This is intentional: the
multi-line PEM secrets in .env use \n escape sequences that dotenvy
parses correctly when reading the file directly, but docker --env-file
passes the literal \n characters through, corrupting the key. For
non-secret env vars only, --env-file would be fine; for the full .env,
mount the file.
Ensure the host .env is readable by uid 1000 (the container's app user):
chmod 644 .env # if your default umask makes it 600
For a multi-arch image (amd64 + arm64) pushed to a registry:
docker login <registry> # if not already authenticated
just docker-build-multiarch ghcr.io/<you>/<repo>:0.1.0
Mount a real volume (-v /srv/app-data:/data or a Docker named volume) and
put a TLS terminator (Caddy, nginx, Cloudflare) in front of port 3000. The
container does no TLS itself.
If you'd rather not use Docker, the release build is a single binary plus a
static/ directory:
just build
# backend/target/release/backend now serves SPA + API from one process.
# Provide a writable directory for SQLite — see DATABASE_URL in .env.example.
Licensed under either of
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.