ADR 0004 — raxx-console Stack: Flask + Jinja2 + HTMX + Tailwind CDN
Status: Accepted
Date: 2026-04-22
Deciders: product owner (user), software-architect
Scope: raxx-console — the operator admin console at console.raxx.app
Context
raxx-console is a separate Heroku app (see docs/architecture/console.md). It needs a UI. The question is what kind. The system is used exclusively by operators (internal staff, small headcount, maybe 5–10 admins at peak). The UI is functional-minimalist: tables, toggles, status badges. There is no public-facing surface, no marketing, no real-time feeds that require WebSocket push. The team already runs a Python Flask app (Raptor) and has validated the Heroku Python buildpack pattern as of PR #137.
Decision
Flask + Jinja2 server-rendered templates, HTMX for interactivity, Tailwind CSS loaded via CDN.
One Python process. No separate API layer. No frontend build step. No Node.js dependency in the raxx-console repo. HTML is generated server-side; HTMX swaps fragments via hx-get / hx-post without a full page reload. Tailwind is loaded from the CDN <script> tag for development and from the CDN <link> in production — acceptable for a low-traffic internal tool where caching the CDN URL is effective.
Consequences
Positive
- Minimal operational surface. One
gunicornprocess, one buildpack, no build pipeline. Deployments are identical toraxx-api-prod. - Server-side renders = no CORS, no JWT-in-browser, no token storage in localStorage. Session cookies with
HttpOnly+SameSite=Strictare the entire auth transport. This closes a class of XSS + token-theft attacks that SPA architectures must defend against explicitly. - HTMX fragments are just HTML partials — testable with Flask's test client, no JS test runner required.
- No duplication of auth logic. There is no client-side session management to build or audit.
- Team familiarity. The team already knows Flask. No context switch to a new framework or build toolchain.
- CDN Tailwind is acceptable for internal tools. The Tailwind CDN tag is ~100KB. For an internal console with no SLA on page-load, this is fine. If the tool ever needs to go offline-capable or the CDN becomes a concern, switching to a bundled CSS file is a one-line change.
Negative / risks
- HTMX is a niche library. New engineers may be unfamiliar. Mitigation: the pattern is simple (annotate HTML attributes, handle
hx-*routes in Flask); a 1-page guide inconsole/docs/htmx-patterns.mdis sufficient. - No client-side state. Complex interactive features (e.g. a live-updating log stream) require either polling HTMX or a minimal
<script>block. For v1 scope (tables, toggles, status badges) this is not an issue. If v2 needs real-time push, we add atext/event-streamSSE endpoint and a small<script>— still no SPA framework. - Tailwind CDN does not tree-shake. Unused utility classes ship. Acceptable for internal tooling; revisit if a Lighthouse score ever matters here (it won't).
- Jinja2 XSS risk. Jinja2 auto-escapes in HTML context by default. Engineers must never use
| safeon user-supplied data. Code review enforces this; there is no user-supplied content to display in v1 (all data comes from Raptor's structured JSON).
Alternatives Considered
FastAPI + React (same pattern as Antlers, new codebase)
Rejected. Adds a Node.js/npm build pipeline to raxx-console. Requires a separate Heroku buildpack or multi-buildpack config. Requires CORS plumbing between a separate React SPA and the Flask backend. The SPA pattern introduces token storage decisions (localStorage is off the table; cookie-based SPAs are complex). React is overkill for tables and toggles used by 5 admins.
FastAPI + Jinja2 + HTMX
Considered. FastAPI's async support is attractive for concurrent Heroku Platform API + Cloudflare API + Sentry API calls on the dashboard. However, the asyncio + Jinja2 combination has more rough edges than Flask + Jinja2, and the concurrent calls are easily handled with concurrent.futures.ThreadPoolExecutor in Flask (as used in Raptor already). Not worth the migration from Flask expertise.
Django + Django admin
Rejected. Django admin gives a lot for free but imposes significant framework lock-in and a learning curve that exceeds the benefit for a bespoke 5-screen tool. Also ships password auth by default — we would spend effort removing it to meet invariant #2.
Streamlit / Gradio (data-app frameworks)
Rejected. These are notebook-adjacent tools, not production web apps. No production-grade session management, no RBAC primitives, no WebAuthn support. Not appropriate for a tool that gates user-account actions.
Full SPA with a separate Flask API (raxx-console-api + raxx-console-ui)
Rejected. Two repos, two deploy pipelines, two sets of logs. Doubles operational burden with no benefit at this scale.
Compliance Checklist
- [x] No password auth ships in the framework by default (Flask + Jinja2 has no auth primitives; we add ours).
- [x] Server-side sessions avoid localStorage token storage (satisfies invariant #1 — nothing credential-like in the browser).
- [x] Jinja2 auto-escaping is on by default (XSS mitigation without effort).
- [x] No client-side secrets (no API keys, no JWTs in JS).
Revisit When
- The team grows to the point where a v2 console needs real-time streaming features beyond what HTMX + SSE can handle cleanly.
- Tailwind CDN uptime becomes a concern (unlikely but swap to bundled file — no framework change required).
- The internal user base grows beyond ~20 admins and performance of server-rendered full-page templates becomes measurable (it won't at this scale).