Architecture Overview¶
This document describes the architecture of the SCALR monorepo. It captures current design — for the rationale behind each decision, see the ADRs in ../adr/.
1. Goals & Principles¶
- Single source of truth — one repo for everything code-related at SCALR.
- Run-anything locally — any individual app can be spun up on its own via its own Dockerfile/compose for development.
- Run-everything together — a top-level
docker-compose.ymlorchestrates the full ecosystem on the server. - Unified authentication — one shared auth service. Users log in once at the portal; access is propagated to every app they're entitled to use.
- API-first — apps communicate with each other and with shared services via well-defined HTTP APIs. No direct DB sharing across app boundaries.
- Clean separation — clear distinction between platform services (auth, billing, gateway), products (subscription apps), client work (per-customer code), and shared libraries.
- Reproducible builds — every service has a Dockerfile; CI builds the same image that runs in prod.
- Agent-buildable — a non-technical user can one-shot prompt an AI agent to spin up a new working app from the template.
- One locked stack for new apps — every new app is FastAPI + React (TypeScript) + Postgres, scaffolded from a single canonical template.
- Self-describing apps — every app declares itself via
app.manifest.yml. The portal, gateway, and CI auto-discover apps from these manifests. There is no central app registry to keep in sync.
2. Top-Level Directory Layout¶
Lines marked (planned) aren't built yet — see PLAN.md for what's coming.
scalr/
├── apps/ # User-facing applications
│ ├── portal/ # Login + application hub
│ ├── products/ # SCALR's subscription products
│ ├── website/ # Marketing site (planned)
│ └── admin/ # Internal admin console (planned)
│
├── clients/ # Client-specific deployments (planned)
│
├── services/ # Shared platform services
│ ├── auth/ # Authentik instance (runs standalone)
│ ├── gateway/ # Traefik edge router + socket-proxy sidecar
│ ├── billing/ # (planned)
│ └── notifications/ # (planned)
│
├── packages/ # Shared libraries (workspace deps)
│ ├── auth-client-py/ # Python OIDC SDK (Depends(require_user))
│ ├── auth-client-ts/ # TS OIDC SDK (useUser, useAuthFetch, AuthProvider)
│ ├── ui/ # Shared React components (Button, Card, AppShell)
│ └── api-types/ # Shared TS types
│
├── template-app/ # The ONE canonical starter app
│ ├── frontend/ # React + TS + Vite + Tailwind
│ ├── backend/ # FastAPI + SQLAlchemy + Alembic
│ ├── scripts/smoke-test.sh
│ ├── docker-compose.yml
│ ├── .env.app # Per-app structural config (committed)
│ ├── app.manifest.yml
│ ├── README.md
│ └── AGENT_INSTRUCTIONS.md
│
├── infra/
│ ├── registered-apps.txt # Drives `make up` — one subproject per line
│ └── scripts/
│ ├── create-app-from-template.sh
│ ├── new-app.py # Interactive wrapper around the above
│ └── validate-manifest.sh
│
├── docs/ # MkDocs site (this directory)
│ ├── mkdocs.yml
│ ├── Dockerfile.dev
│ ├── docker-compose.yml
│ └── content/
│
├── schemas/
│ └── app-manifest.schema.json
│
├── .env.shared.example # Template for the secrets file (committed)
├── .env.shared # Real secrets (gitignored — sync manually
│ # between machines, see ADR 0008)
├── docker-compose.yml # Intentionally a 12-line placeholder; real
│ # orchestration is per-subproject via
│ # `make up` (see ADR 0005)
├── Makefile # `make up`, `make down`, `make new-app`
├── PLAN.md # What's not yet built
└── README.md
3. Application Anatomy¶
Every app under apps/ follows the shape defined by template-app/:
apps/<kind>/<slug>/
├── frontend/ # React + TypeScript + Vite + Tailwind
│ ├── src/
│ │ ├── App.tsx # Top-level component (auth-aware)
│ │ ├── main.tsx # Wraps App in <AuthProvider>
│ │ └── index.css
│ ├── Dockerfile # Production multi-stage build (nginx)
│ ├── Dockerfile.dev # Vite dev server with HMR; mounts packages/
│ │ # live so @scalr/* edits hot-reload
│ ├── package.json
│ └── vite.config.ts
├── backend/ # FastAPI + SQLAlchemy + Alembic
│ ├── src/
│ │ ├── main.py # FastAPI app entry; registers routers
│ │ ├── models.py # SQLAlchemy models
│ │ ├── schemas.py # Pydantic request/response schemas
│ │ ├── routes/ # One file per endpoint group
│ │ ├── deps.py # Re-exports require_user from auth-client-py
│ │ ├── db.py # SQLAlchemy engine + session
│ │ └── settings.py # Env loader (pydantic-settings)
│ ├── alembic/ # DB migrations
│ ├── alembic.ini
│ ├── entrypoint.sh # Runs `alembic upgrade head` before uvicorn
│ ├── Dockerfile # Production build
│ ├── Dockerfile.dev # uvicorn --reload
│ └── pyproject.toml
├── scripts/
│ └── smoke-test.sh # Bash + curl + jq — see ADR 0006
├── docker-compose.yml # Frontend + backend + db, joins scalr-edge
│ # so Traefik can route to it
├── .env.app # Structural config (committed; see ADR 0008)
├── app.manifest.yml # Metadata — see reference/app-manifest.md
├── README.md
└── AGENT_INSTRUCTIONS.md
Rules:
- Frontend is always React + TypeScript + Vite.
- Backend is always FastAPI + SQLAlchemy + Alembic + Postgres.
- Each app gets its own Postgres database.
- Apps never import each other's code directly. They talk via APIs.
- Apps may import from packages/.
- Every app has an app.manifest.yml.
4. Authentication¶
Provider: Authentik, self-hosted in services/auth/. See ../adr/0003-authentik-for-auth.md for why.
Token & SDK design¶
- Authentik issues OIDC access tokens (JWT, RS256, short-lived ~15min) and refresh tokens.
- Tokens are validated by apps via JWKS — apps fetch Authentik's public keys once and cache them.
packages/auth-client-pyis a thin OIDC wrapper exposing a FastAPI dependency:Depends(require_user).packages/auth-client-tsexposes<AuthProvider>plus hooksuseUser(),useAuth(),useAuthFetch().AuthProviderauto-redirects to Authentik when there's no session (autoSignIn=trueby default), so apps never render a blank "you're not signed in" state — the redirect is silent if SSO already has a session at Authentik.- Both SDKs target generic OIDC, not Authentik-specific APIs. Provider lock-in is contained to
services/auth/configuration only.
Flow¶
1. User opens http://<slug>.localhost (e.g. portal, hello, calculator)
│
▼
2. Traefik routes to <slug>-frontend container; Vite serves the SPA
│
▼
3. AuthProvider starts: no local session → redirect to Authentik
│
▼
4. Authentik (http://auth.localhost):
• already signed in? → silent redirect with auth code
• not signed in? → show login form, then redirect with code
│
▼
5. AuthProvider exchanges code for an access token, stores it, sets user
│
▼
6. App calls /api/* on the same origin (http://<slug>.localhost/api/…)
→ Traefik routes to <slug>-backend
→ backend's Depends(require_user) validates the JWT against Authentik's JWKS
→ response, or 401 if invalid
The flow is the same for every app — every app is its own OIDC client. Cross-app SSO works because Authentik holds the session and recognises the user across apps.
Entitlements¶
Authentik's "Application" + "Group" model handles which users can use which apps. The portal queries Authentik to get the list of applications the current user has access to, then matches them by slug against the discovered app.manifest.yml files.
5. Inter-App Communication¶
- Per-app routing: Traefik routes
<slug>.localhost(dev, HTTP) /<slug>.${SCALR_PUBLIC_DOMAIN}(prod, HTTPS via Let's Encrypt) to each app's frontend, and…/api/*to its backend. Frontend ↔ backend talk same-origin — no CORS — so an app's frontend just calls/api/*and Traefik does the right thing. The public domain is set viamake set-public-domain; seeDEPLOYMENT.mdfor the full prod setup. See also ADR 0007. - Cross-app calls (synchronous): apps reach each other over the shared
scalr-edgeDocker network using container DNS (e.g.http://portal-backend:8000/api/...). Cross-app HTTP carries an OIDC token — same auth wiring as user-driven calls. - Asynchronous: message bus is still open (see
open-questions.md#3). Manifest already hasevents_publish/events_subscribefields so wiring can be captured before a bus is chosen. - Type safety: API contracts live in
packages/api-types(currently a stub, deliberate). - No shared databases across app boundaries. Each app owns its data; each Postgres is on its own private network and physically unreachable from other apps.
6. Docker Strategy¶
Per ADR 0005, there is no monolithic root compose. The root docker-compose.yml is a 12-line placeholder explaining as much. Real orchestration is per-subproject, driven by the Makefile.
make up — the canonical bring-up¶
infra/registered-apps.txt lists every subproject (one per line). make up iterates the list, running docker compose up -d --build in each, with two --env-file flags (<repo>/.env.shared and ./<app>/.env.app) so secrets and structural config merge cleanly. See ADR 0008.
# First-machine bootstrap:
cd services/auth && docker compose up -d # Authentik, runs standalone
cd <repo-root>
cp .env.shared.example .env.shared # then edit it with real secrets
# Then, from the repo root:
make up # gateway + portal + every registered app
make down # stop everything (preserves volumes; auth keeps running)
make down-v # also drop DB volumes (DESTRUCTIVE; auth still preserved)
make ps # what's running
Auth is intentionally not in make up — it keeps its own Postgres volume across make down cycles to preserve users and OIDC config.
Per-app standalone (rarer)¶
For working on one app in isolation, the same flags work directly:
cd apps/portal
docker compose --env-file ../../.env.shared --env-file ./.env.app up -d
make up is preferred because it gets the depth-relative paths right automatically.
Dockerfile conventions¶
Dockerfile— multi-stage, minimal, production-ready (frontend builds with nginx; backend with uvicorn).Dockerfile.dev— Vite dev server / uvicorn--reload. Frontend's dev image bakes in workspace packages once at build time, but the compose file bind-mountspackages/auth-client-ts/srcandpackages/ui/srcover those paths so library edits hot-reload without rebuild.
7. Client Work vs Subscription Products¶
| Aspect | apps/products/* (subscription) |
clients/* (client-specific) |
|---|---|---|
| Audience | Many customers, multi-tenant | One client, possibly bespoke |
| Lifecycle | Long-lived, versioned | May be short-lived or handed off |
| Auth | Uses central auth | Uses central auth (same SSO) |
| Deployment | Always part of main compose | Deployed per-client, often separately |
| Code reuse | Heavy use of packages/ |
May fork product code or extend it |
Whether
clients/lives in this repo or in separate repos is still open — seeopen-questions.md.
8. The Template App¶
template-app/ is a fully working, runnable application. Not a skeleton. It's a real app that says "Hello, {user.name}" after you log in. You can cd template-app && docker compose up and it works.
Includes out of the box¶
- Frontend: React + TS + Vite + Tailwind, with login redirect to the portal and one example authenticated page.
- Backend: FastAPI with one example authenticated endpoint, SQLAlchemy model, Alembic migration.
auth-client-pyandauth-client-tsintegration — token validation works on day one.- Postgres dependency wired up via compose with init scripts.
app.manifest.ymlso the portal can discover it.Dockerfile+Dockerfile.devfor both frontend and backend..env.appwith every variable the template needs.scripts/smoke-test.sh.README.mdfor humans.AGENT_INSTRUCTIONS.mdfor AI agents.
Creating a new app¶
There are two entry points to the same infra/scripts/create-app-from-template.sh script:
# Interactive — recommended. Prompts for name, slug, description, kind.
python3 infra/scripts/new-app.py
# Non-interactive
make new-app NAME="<Display Name>" SLUG=<kebab-slug> KIND=product|client|internal|platform \
DESCRIPTION="<one sentence>"
The script's mechanical steps:
- Validates inputs (slug kebab-case, unique against every existing manifest, not
template-app). - Copies
template-app/to the right location based on kind:apps/products/<slug>/,clients/<slug>/,apps/<slug>/(internal), orservices/<slug>/(platform). - Strips dev cruft (
__pycache__,node_modules, any stray.env). - Allocates the next port range (frontend +10, backend +10, db +1 from the highest existing).
- Substitutes the template's slug-derived values throughout (slug, name, description, kind, DB name, ports, manifest UUID, manifest creation date).
- Fixes depth-relative paths: compose
context: ..deepens for the new dir;../packages/*live-mount paths andfile:../../packages/*workspace deps infrontend/package.jsonare deepened too. - Validates the new manifest (warns if
check-jsonschemaisn't installed). - Appends the new path to
infra/registered-apps.txtsomake uppicks it up.
The script does not:
- Create the OIDC client in Authentik (still a manual UI step — see ../guides/configuring-authentik-oidc.md).
- Boot the app (the interactive new-app.py optionally does, the bash script doesn't).
- Run the new app's smoke test.
Agents call the script first, then apply the "modify an existing app" procedure to add the user-specific feature. See ../guides/creating-an-app-with-ai.md.
9. Smoke Tests¶
Hybrid strategy:
| Test | Runs | Speed | What it catches |
|---|---|---|---|
smoke-test.sh |
After every scaffold; after every agent change; CI on every push | ~5s | Wiring, config, auth, container boot, routing, manifest |
smoke.spec.ts (E2E) |
CI nightly + manually on demand | ~30-60s | Full browser flow: login → token → frontend renders |
The agent only ever runs the fast one. Humans and CI run both. See ../adr/0006-hybrid-smoke-tests.md.
10. Documentation Site (MkDocs)¶
The repo ships its own documentation as a first-class app under docs/.
- Markdown-native — same files used by humans, AI agents, and the docs site.
- Static output — cheap to host, fast to load, easy to put behind the gateway.
mkdocs-materialtheme: search, navigation, dark mode, Mermaid diagrams.- The
AGENT_INSTRUCTIONS.mdfile intemplate-app/is the source of truth; the docs site includes it via MkDocs' include plugin so it can never drift. - The docs site is deployed as part of the main compose at
docs.scalr.com.
11. Tooling & Conventions¶
Locked decisions:
- Frontend: React 18 + TypeScript + Vite + Tailwind. Shared components from packages/ui.
- Backend: Python 3.12 + FastAPI + SQLAlchemy 2.x + Alembic.
- Database: Postgres 16. One DB per app.
- Auth: Authentik, accessed via OIDC. See ADR 0003.
- Gateway: Traefik v3 with a tiny nginx socket-proxy sidecar. See ADR 0007.
- Env file split: <repo>/.env.shared (gitignored, secrets) + <app>/.env.app (committed, structural). See ADR 0008.
Planned but not yet wired: - Linting/formatting: Prettier + ESLint (frontend), Ruff + Black (backend), to be enforced in CI. - CI/CD: GitHub Actions — see PLAN.md M8.
Still open — see open-questions.md.