Introduction
A demo link shortener for ScottyLabs, showcasing the full deployment stack:
- Rust backend with Axum, Sea-ORM, and utoipa (OpenAPI)
- Keycloak OIDC authentication via axum-oidc through the shared ScottyLabs OAuth relay
- Svelte 5 frontend with Vite and openapi-fetch, built with the Deno toolchain
- PostgreSQL 18 with pg_uuidv7
- Nix flake built with the shared scottylabs helpers (crane for the backend, a Deno task builder for the web app, mdbook for docs) and deployed by the ScottyLabs platform
Authenticated users can create, manage, and delete short links. Anyone can follow a short link to its target URL.
Setup
Prerequisites
Getting started
Log in to OpenBao once per machine. The shell resolves secrets as it loads and will not activate without a token, so run this on a fresh checkout before entering the shell for the first time:
nix run git+https://codeberg.org/ScottyLabs/devenv#login
The token renews on each shell entry, so you only repeat this on a new machine.
Let direnv activate the environment automatically when you enter the directory:
direnv allow
Start PostgreSQL in the background, and stop it when you are done:
# Start
devenv up -d
# Stop
devenv processes down
Run the backend and frontend in separate terminals:
# Backend API on port 3000, applies pending migrations on startup
cargo run -p link-shortener
# Frontend dev server on port 5173 with hot reloading
cd sites/web && deno task dev
The shell also provides helper commands: generate-api regenerates the typed API client from the running backend, migration NAME scaffolds a new migration, migrate applies pending migrations manually, generate-entities regenerates the SeaORM entity code from the database schema, and docs serves this documentation locally.
Secrets
Secrets are managed with secretspec and resolved from the ScottyLabs vault. The dev environment wires this up in devenv.yaml, so the values are present in the shell and inherited by any process you start from it. The complete set is declared in secretspec.toml:
KEYCLOAK_URLandKEYCLOAK_REALMlocate the Keycloak realmOIDC_CLIENT_IDandOIDC_CLIENT_SECRETare the confidential client credentialsOAUTH_RELAY_URLis the shared OAuth relay callback that Keycloak redirects to
Neither DATABASE_URL nor APP_URL is a secret, and the deployment platform injects both in production. Locally the devenv PostgreSQL service provides DATABASE_URL and scottylabs.ricochet.appUrl provides APP_URL.
OIDC client
Authentication uses a confidential Keycloak client. Because the deployment shares one OAuth relay across apps, the client redirect URI is the relay (OAUTH_RELAY_URL) rather than the app. The relay records the per request return target in the OAuth state and forwards the authorization code to {APP_URL}/auth/callback, so each app receives its own callback without registering a separate redirect URI.
Architecture
Crate structure
crates/
link-shortener/ Entry point, wires everything together
link-shortener-api/ Axum routes, OIDC auth, OpenAPI via utoipa
link-shortener-store/ Sea-ORM repository layer
entity/ Generated Sea-ORM entity models (do not edit)
migration/ Sea-ORM database migrations
Auth flow
- The user clicks “Log in” on the frontend.
- The browser navigates to a protected route such as
/api/me, which is behindOidcLoginLayer. - axum-oidc redirects the browser to Keycloak. The redirect URI registered with the client is the shared OAuth relay, and the OAuth state carries a CSRF token plus the app callback to return to (
{APP_URL}/auth/callback). - The user authenticates with Keycloak.
- Keycloak redirects to the OAuth relay with an authorization code.
- The relay reads the return target from the state and forwards the code to
{APP_URL}/auth/callback. /auth/callbackexchanges the code for tokens and creates a server side session.- The browser is redirected back to the app, now authenticated.
- The frontend calls
/api/meagain and receives user info from the session.
API design
Routes are registered with utoipa-axum for automatic OpenAPI schema generation. The schema is served at /openapi.json and is browsable at /swagger-ui.
Protected routes require a valid session (enforced by OidcLoginLayer):
GET /api/meGET /api/linksPOST /api/linksPATCH /api/links/{id}DELETE /api/links/{id}
Public routes do not require authentication:
GET /api/healthGET /auth/callback(OIDC redirect handler)GET /{slug}(slug redirect, handled by the router fallback)
Static files and slug redirects
In production the backend serves the built SPA itself. When STATIC_DIR is set, a ServeDir serves the static files and uses the slug handler as its not found service. The slug handler checks whether the path is a single segment matching a slug in the database; if so it returns a 307 redirect, otherwise a 404. When STATIC_DIR is unset, as in local development, the SPA is served by the Vite dev server and the backend fallback is the slug handler alone.
Frontend
The frontend is built with Svelte 5 and Vite on the Deno toolchain. Auth state is determined by calling /api/me on mount. The Vite dev server proxies /api, /auth, /swagger-ui, and /openapi.json to the backend on port 3000.
Once the backend is running, deno task generate-api generates TypeScript types from the OpenAPI schema via openapi-typescript. The openapi-fetch library provides a typed client for making API calls.
Deployment
The flake exports the packages the ScottyLabs platform builds and serves. There is no self contained NixOS module; deployment is declared in devenv.nix and the platform builds the flake on every push.
Flake packages
| Attr | Contents |
|---|---|
link-shortener | Backend binary with the built SPA baked in via STATIC_DIR |
web | The built SPA as static files |
docs | This documentation site, built with mdbook |
default | Alias for link-shortener |
Every package is built with the shared scottylabs helpers: buildRustService (crane) for the backend, buildDenoTask for the SPA, and buildMdbook for the docs. The backend is wrapped so STATIC_DIR points at the web package, which is why the single binary serves both the API and the SPA.
Deployment configuration
Domains are declared in devenv.nix under the scottylabs deployment options:
scottylabs.kennel.services.link-shortener.customDomain = "cmu.lol";
scottylabs.kennel.sites.docs.customDomain = "docs.cmu.lol";
A services key must match a flake package attr, which the platform runs as the backend and exposes on its custom domain with TLS. A sites key must match a flake package attr, whose built static files are served from that package. Here the link-shortener service serves both the API and the SPA, and the docs site serves this documentation.
Secrets
Runtime secrets are declared in secretspec.toml and resolved from the ScottyLabs vault at deploy time. The backend reads them as environment variables:
| Variable | Purpose |
|---|---|
KEYCLOAK_URL, KEYCLOAK_REALM | Keycloak realm location |
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET | Confidential OIDC client credentials |
OAUTH_RELAY_URL | Shared OAuth relay callback registered with Keycloak |
DATABASE_URL and APP_URL come from the environment rather than secretspec: the platform injects both at deploy time, while in development the devenv PostgreSQL service provides DATABASE_URL and scottylabs.ricochet.appUrl provides APP_URL. PORT defaults to 3000, RUST_LOG to a built-in filter, and STATIC_DIR is set by the build to the bundled SPA.