Architecture & Infrastructure

How the RISE AI Interview Coach is built, deployed, and operated — the application layers, data model, external integrations, hosting topology, and the automated release pipeline.

Product
RISE — AI Interview Coach
Document
Architecture & Infrastructure
Version
2.2
Date
2026-09-15 (v2.1: 2026-09-14 · v2.0: 2026-09-03 · v1.0: 2026-07-07)
Author
Zoran Krnetić (PR BRIGHTNESS)

00 — Executive Summary

Executive Summary

RISE is the AI-powered interview-coaching platform delivered under the Erasmus+ project KA210-VET-778D8F70. It lets vocational-school students practise realistic job interviews and receive immediate, rubric-based feedback in their own language, while educators and counsellors follow each student's progress. This document describes the technical architecture and infrastructure that deliver that vision — and shows how each engineering choice maps to a stated objective of the funded proposal.

The platform's guiding principle is a small, low-cost, provider-agnostic footprint that protects student data by default and can grow — from a single server, to a school's own network, to the cloud — without being rebuilt.

v2.2 — 15 September 2026. One new layer, and two sentences that had stopped being true:

  • Statistics. A fourth layer beside the routers, the services and the queries: the rules that decide what a period means and when a cohort is too small live in a module that touches nothing, so they are testable as functions. One router mounts twice and takes its scope from the actor, never the request.
  • Aggregates are now an explicit exception to "nobody but the student and their teachers", stated beside the access model rather than left to be inferred — bounded by queries that only ever return groups, and by a five-student reporting threshold.
  • Production no longer sends through Resend. Mail goes through the school's own mailbox, which receives as well as sends; the second transport is kept for a school with no mail server of its own. The document said the opposite.
  • Routers are no longer five. They were counted before two were added.

v2.1 — 14 September 2026. Four tables the document had never named, and one sentence that had become untrue:

  • The data model was four tables short — password_resets, platform_settings, org_settings and transcription_calls. The last had been missing since voice input shipped.
  • An account can be suspended, and a student's interviews can be capped. Both are new state on existing rows, and both are enforced on paths this document traces — so §05 now traces starting an interview as well as answering in one.
  • A school completes its own privacy notice. The retention periods it sets are the ones the notice publishes, which changes what "retention is outstanding" means: the numbers are decided, the deletion job that honours them is not.
  • Mail has two transports and the choice is declared, not inferred from which variables are set.

v2.0 — 3 September 2026. Three changes since v1.0 alter the shape of the system rather than its details, and each is why a section below now reads differently:

  • The platform serves three schools, separated from each other. It was written for one. A teacher now reaches only students of their own school; a platform-administrator role sits above all of them and can read no student data.
  • Content is versioned, and every interview records the version it was taken against. Deleting a question can no longer make a finished interview unreadable — which, before this, it did silently.
  • The model provider is Scaleway, in Paris, not Together.ai. Changed in August 2026 for EU data residency: these are answers written by minors.

Project goals & how the architecture meets them

Goal (KA210-VET proposal)How the architecture delivers it
AI-driven interview practice with real-time, structured feedbackA server-controlled interview engine calls a hosted LLM per answer and returns validated, rubric-scored JSON feedback and a session summary.
Multilingual & inclusive (Serbian, English, French, Portuguese)Four-language UI and content; feedback generated in the student's language.
Accessible across devices (desktop, tablet, mobile) and for SEN studentsResponsive single-page app; accessibility baseline extended at launch (see §12 and the platform PRD).
Data privacy & GDPR compliance for minors and SEN studentsInference and hosting inside the EU; pseudonymous student accounts; every staff read of a student record recorded; a person downloads their own data themselves. Student data can stay entirely on-site via the on-prem model (§15). See §12.
Progress tracking & analytics for students and educatorsFull interview history, scores, and per-answer feedback persisted in PostgreSQL, ready for longitudinal dashboards and KPI export.
Educator / counsellor guidance tailored to each studentRole-based access (student · teacher · school administrator · platform administrator), scoped to the teacher's own school. Neither administrator role can read a transcript; the only readers of a student's answers are that student and teachers at their own school.
Staff-managed content without developer involvementEach school points the platform at its own spreadsheet and imports it when ready — no code change to add content, and editing a sheet changes nothing until it is imported.
Scalable, reliable, modular architecture for future expansionStateless app behind a proxy, isolated module boundaries for AI, email, content, and persistence; scales horizontally and swaps providers cleanly.
Low-cost, sustainable operationRuns on a single modest host; per-message model/token accounting provides the basis for cost and eco-footprint reporting.
Provider independence & open, portable deploymentOne codebase runs on a managed VPS, a school's own network with a local LLM, or AWS — configuration only, no re-architecture (§15).
Reading this document. Sections 01–08 describe the application and its runtime; 09–14 cover hosting, delivery, security, operations, and scale; 15 presents alternative deployment models. Where a topic maps to a launch-readiness commitment, it references the platform PRD.

01 — Introduction

Overview & Principles

RISE is a server-rendered-agnostic web application: a React single-page app served by a single Node.js/Express process, backed by PostgreSQL, with AI feedback from a hosted large-language model and interview content managed in a spreadsheet CMS. The design favours a small, low-cost, provider-agnostic footprint that can run on one modest server and move to larger infrastructure without rework.

Architectural principles

  • Simple by default. One deployable process; no container orchestration, no message brokers, no microservices at this scale.
  • Provider-agnostic. Stack runs on any Linux VPS, cloud VM, or on-prem appliance; no proprietary cloud lock-in.
  • Modular seams. Content source, AI provider, email, and persistence are isolated modules, so each can be swapped independently.
  • Stateless app, stateful DB. All durable state lives in PostgreSQL; the Node process holds no session state in memory.
  • Secrets outside the repo. All credentials come from an environment file that is never committed.

At a glance

ConcernChoice
FrontendReact 19 + Vite 6 SPA
BackendNode.js + Express 4
DatabasePostgreSQL
AIScaleway Generative APIs (Qwen), Paris
Content storePostgreSQL (imported from Sheets)
Content authoringGoogle Sheets → DB import
EmailResend (transactional)
Edgenginx reverse proxy + TLS
Runtime mgmtsystemd
ReleaseGitHub push-to-production (CI/CD)

02 — The big picture

System Context

The browser talks to a single application server through nginx. That server owns the database and, at request time, calls out to just two managed services — the LLM provider and the email provider. Interview content lives in PostgreSQL; a spreadsheet is only a human authoring surface, imported on demand and never read during a user request. Each of the three schools has its own spreadsheet and its own content, and one system holds all three, separated from each other.

Users Students · Teachers · Admin Browser (SPA) nginx TLS · proxy RISE App Server Node.js · Express · :3002 REST API + static SPA Session & auth PostgreSQL Users · sessions · feedback Scaleway Qwen LLM Google Sheets Content authoring offline import only Resend Invitation email HTTPS SQL import HTTPS
Client RISE / edge Data store Content 3rd-party service

03 — Technology

Technology Stack

Deliberately mainstream, well-supported components with no exotic dependencies — chosen so the platform is cheap to run and easy to hand over.

LayerTechnologyRole
ClientReact 19, React Router 7, Vite 6Single-page app; built to static assets by Vite
ServerNode.js, Express 4REST API, session handling, serves the built SPA
Authexpress-session, connect-pg-simple, bcryptjsCookie sessions stored in Postgres; hashed passwords
ValidationZodSchema validation of Sheets rows and LLM JSON output
DatabasePostgreSQL (pgcrypto)Durable store; UUID keys; JSONB feedback
AIScaleway Generative APIs — Qwen (OpenAI-compatible), Paris regionRubric feedback & session summaries as structured JSON. Chosen over Together.ai in August 2026 for EU data residency: student answers are written by minors and must not leave the EU
Content storePostgreSQLScenarios, questions, rubrics served from the database
Content authoringGoogle Sheets API (google-auth-library)Human editing surface; imported into the DB offline
EmailResendTransactional invitation emails
EdgenginxTLS termination, reverse proxy to :3002
RuntimesystemdProcess supervision, restart, logging
OSLinux VPSSingle host; provider-agnostic
Dependency discipline. The backend ships seven runtime dependencies and the frontend three. A small surface area keeps the security-patch burden low and the build fast.

04 — Application

Application Architecture

One Express process exposes a REST API under /api and serves the compiled React app for everything else, with an SPA fallback. Internally the backend is organised into thin routers over a set of focused service modules.

Frontend (SPA) Pages · Router AuthContext (session) api.js (fetch client) i18n (sr/en/fr/pt) Components Built by Vite → /frontend/dist HTTPS · JSON · cookie session Backend — Express Routers auth · admin invitations scenarios sessions Service modules llm/together content/import auth/middleware email/resend db/pool · db/migrate (pg) Static file serving + SPA fallback PostgreSQL Scaleway Google Sheetsoffline import Resend

Frontend

  • React SPA built by Vite to static assets, served by the same Node process — no separate web server or CDN required.
  • AuthContext holds the logged-in user; the API is called with credentialed fetch (session cookie).
  • Four-language UI (sr/en/fr/pt) via static translation modules.

Backend

  • Routers mount under /api, one per audience — auth, student, teacher, school administrator, platform administrator — plus a /health endpoint for uptime checks.
  • Business logic lives in service modules (LLM, Sheets, email, auth, DB) — routers stay thin.
  • The interview state machine (next question, scoring, summary) lives server-side; the AI never drives flow.
  • Statistics are a fourth layer, deliberately separated. stats/rules.js holds the decisions and touches nothing — which window a period means, what it compares against, when a cohort is too small to publish — so they are testable as functions rather than as fragments of a query. db/stats.js is only SQL. stats/report.js runs them together. One router mounts twice, at /api/platform/statistics and /api/admin/statistics, and takes its scope from the actor rather than the request.

05 — Runtime

Request Lifecycle — Submitting an Answer

The most involved path in the system: a student submits an answer and receives structured AI feedback. In a single request it reads and writes the database and calls the LLM — content is already local, so the spreadsheet is never touched.

  1. Browser → nginx → app. A credentialed POST /api/sessions/:id/answers arrives; nginx forwards it to Node on 127.0.0.1:3002 with forwarding headers.
  2. Authenticate, authorize, and place. The session cookie is resolved against the Postgres session store. The caller's organisation is then read from the database, not from the cookie — which is also where a suspended school is refused — and the route confirms the caller owns the in-progress session.
  3. Load scenario, at the session's own version. The scenario, questions and rubric are read from PostgreSQL at the content version this interview was started on — not at whatever is current. A question edited or deleted since the student began cannot change or vanish from an interview in progress.
  4. Persist the answer. The student's answer is written to interview_messages.
  5. Call the LLM. A rubric-grounded prompt goes to Scaleway in JSON mode, under a per-call timeout; the response is validated against a Zod schema, with one repair retry on failure.
  6. Persist feedback. Validated feedback is stored in answer_feedback (JSONB + overall score) and an AI message row records model + token usage.
  7. Advance the state machine. The server computes the next unanswered question; if none remain it generates a session summary and marks the session completed.
  8. Respond. The feedback and next question (or summary) return as JSON; the SPA renders the feedback block. What crosses to a student is filtered by role: expected answers and rubric weights never do — they are the answer key and the mark — while rubric labels do, so the competency is named in the student's own language rather than as a prettified database key.

And the shorter one — starting an interview

Worth tracing separately because it is the only path that can refuse, and because what it refuses on is counted at exactly one moment.

  1. Authenticate and place, as above. Only a student may start one.
  2. Resolve the scenario against the school's current content version, and refuse a retired one — past interviews still open on retired content, but no new one begins on it.
  3. Check the allowance. Three limits in order of how final they are: the student's total, then their attempts at this scenario, then the per-scenario waiting period since their last attempt at it. The first two are counted from sessions started, finished or not; the third is measured from the previous attempt's start, because completed_at is null on an abandoned session and a cooldown anchored there would never begin for somebody who opens an interview and walks away.
  4. Insert and respond with the first question.
Nothing on this path can strand a half-finished interview. The limits are consulted when a session is created and nowhere else: resuming and answering never look at them, so a student who opened three interviews and abandoned all three may come back weeks later and finish every one. A cap that could strand an interview would punish exactly the student who came back to it.

The check reads and the insert writes with no lock between them, so two simultaneous starts can both pass and take a student one over. Left as it is deliberately — serialising every start in the platform to remove, at worst, one extra interview for one student is the wrong trade. The limit is a guard rail against a runaway bill, not a ledger.
Latency note. The only external call on this path is the LLM request, which runs synchronously inside the HTTP request and is its dominant cost. Everything else — scenario content, session state, feedback — is a local database read/write. This is a candidate for asynchronous handling as load grows (see Scalability).

06 — Data

Data Model

Four core interview tables (below), a set of imported content tables, and a session store — all in PostgreSQL. UUID primary keys (pgcrypto), foreign keys with cascade delete, CHECK constraints for enumerations, and JSONB for AI feedback. Schema is versioned by an in-repo migration runner.

users id (uuid PK) email · password_hash role · display_name preferred_language interview_sessions id (uuid PK) student_id → users scenario_id · language status · attempt_number started_at · completed_at interview_messages id (uuid PK) session_id → sessions sender · message_type content · question_id llm_model · *_tokens answer_feedback id (uuid PK) session_id → sessions answer_message_id → msg student_answer ai_feedback_json (jsonb) overall_score
Identity Session state Transcript Scored feedback

Tenancy

  • organizations — slug, name, country, default language, and a status that blocks sign-in when suspended.
  • users.org_id — required for every role except super_admin, which must have none. Both directions are a CHECK constraint, not a convention.
  • user_invitations.org_id — an invitation carries the organisation it was issued for, so accepting a link cannot place someone in a school nobody chose.
  • groups, group_teachers, group_students — classes within a school. Built and deliberately empty. Scoping is additive: a teacher with no group assignment sees their whole school, so an empty table behaves exactly as if groups were switched off.
  • org_settings — one row per school, created lazily: the paragraphs of the privacy notice only a school can supply (controller identity, data protection officer, lawful basis, retention periods, supervisory authority) and the support contact its students are shown. Kept off organizations deliberately — that row is read on every request through loadActor, and a data protection officer's telephone number has no business being carried through all of them to serve a page opened twice a year.
  • platform_settings — a single row, enforced by a boolean primary key with a CHECK, holding what every school inherits: the interview limits and the platform's own support address. A school overrides a limit by setting its own; NULL means "whatever the platform says".
  • users.status — active or suspended. Two values and not three: to the person holding the account, it is either usable or it is not.

Content

  • org_content_source — which spreadsheet a school imports from, and the result of the last connection test. No credentials are stored: access is granted by sharing the sheet with the platform's service account.
  • content_versions — one row per import, at most one current per school.
  • content_scenarios, content_questions, content_rubrics — keyed by version. An import never overwrites; it adds a version and makes it current, so reverting a bad import is a flag, not a reconstruction.
  • interview_sessions.content_version_id — the pin, ON DELETE RESTRICT.

Accountability & rights

  • audit_log — every staff read of a student's record: actor, action, subject, time. It holds no content, and survives the deletion of the account it describes, so an erasure cannot quietly erase the evidence that the data was read. Its CHECK-constrained action now also admits suspend and reactivate: suspension is done to a data subject and nothing else would record who did it. Reactivation is logged for the same reason — an account suspended and quietly restored is the shape of a mistake.
  • data_requests — erasure requests as rows with a stored due date, because Article 12(3) starts a one-month clock and an inbox has neither a due date nor evidence.
  • answer_criterion_scores — per-competency scores as rows rather than inside the JSON, so reporting is a query and the numbers survive encryption of the prose.
  • answer_feedback.answer_char_count — kept outside the text it measures, for the same reason.

Supporting

  • session — the connect-pg-simple cookie-session store, auto-created. Note it stores sess as json, not jsonb, which is why ending somebody's open sessions matches on sess ->> 'userId' and never ->: there is no equality operator against jsonb.
  • password_resets — single-use, one-hour tokens, stored as a hash. The only self-service recovery route in the platform, and therefore the reason an account on an address that receives no mail cannot recover itself.
  • transcription_calls — one row per dictation sent to Whisper: model, audio seconds, and the cost, CO₂ and water estimates that go with it. No audio and no transcript. The recording is held in memory for one upstream call and dropped, so there is no recording of a minor's voice anywhere in this system — which is what makes the privacy notice for that feature short.
  • Per-message llm_model, token, cost, CO₂ and water columns — the foundation for cost & sustainability reporting. Costs are estimates from the retail catalogue, never billed amounts, and carry the rate-table version that produced them.

Integrity & performance

  • Enumerations enforced by CHECK constraints (role, status, language, sender, message_type).
  • Cascade deletes keep sessions, messages, and feedback consistent when a user is removed.
  • Composite indexes on (student_id, started_at) and (session_id, created_at) for the common list/replay reads, and (student_id, scenario_id) for the interview-limit check that runs on every attempt to start one.
Migrations. A lightweight in-repo runner applies ordered SQL files (0001…0006) idempotently at deploy time — no external migration tool. Roles grew from student/teacher to include admin, and languages from sr/en to add fr/pt, each as its own migration.

07 — External services

External Integrations

Two managed services are called at request time — the LLM and email — each behind its own module so it can be swapped or mocked independently. A third integration, Google Sheets, is used only offline to author content and import it into the database; it is never on the user request path. All calls are outbound HTTPS authenticated with secrets from the environment file.

Scaleway — LLM

  • Qwen instruct model via the OpenAI-compatible chat API, in JSON-object mode.
  • Output validated against Zod schemas (feedback & summary); one repair retry with the specific validation errors on failure.
  • Low temperature for reproducible scoring; model + token usage recorded per call.

Google Sheets — Authoring & import

  • A spreadsheet is the human-friendly surface where school staff write scenarios, questions, and rubrics.
  • An admin-triggered import reads the sheet (service-account JWT via google-auth-library), validates rows with Zod, and upserts them into PostgreSQL.
  • After import the app serves all content from the database — the Sheets API is never called during a student or teacher request, so a Sheets outage cannot affect live sessions.

Email — Resend or SMTP

  • Sends transactional invitation and password-reset mail with a hashed, single-use token link.
  • Two transports, and MAIL_TRANSPORT chooses. A host may legitimately carry the configuration for both, so the choice is declared rather than inferred from which variables happen to be set — inference is how a host ends up sending through the transport nobody meant to use, with two plausible explanations and only one of them looked at.
  • SMTP, through the school’s own mailbox. That address both sends and receives, which is what an invitation telling a fifteen-year-old to click a link needs: somewhere the reply goes to a person. Resend remains supported and unused — kept as the second option for a school with no mail server of its own, not as the default. The privacy notice names only the providers that actually hold data, so it no longer lists a mail service at all: the mailbox belongs to the same provider as the servers.

Resilience posture

  • The live request path depends on only the database and the LLM; content is local, so a Sheets outage cannot affect sessions.
  • LLM failures surface as clean API errors, not crashes; a timeout and bounded retry prevent a slow upstream from hanging a request.
  • Import failures are isolated to the admin import action and never reach end users.
  • Secrets for all integrations are injected via the environment file, never in code.

08 — Security

Authentication & Sessions

Classic, robust server-side sessions — not JWTs in local storage. The session identifier lives in a hardened cookie; all session state is in PostgreSQL.

PropertyValueWhy
Mechanismexpress-session + connect-pg-simpleServer-side sessions persisted in Postgres; survive restarts
Cookie namerise.sidOpaque session id only — no user data in the cookie
FlagshttpOnly, sameSite=lax, secure (prod)Not readable by JS; CSRF-resistant; HTTPS-only in production
Lifetime7 daysBalances convenience against stale sessions
Passwordsbcryptjs hashesSalted, slow hashing; plaintext never stored
Proxy trusttrust proxy = 1 (prod)Correct protocol/secure-cookie behaviour behind nginx
Rolesstudent · teacher · admin · super_adminRoute-level authorization, ownership checks on sessions, and an organisation predicate on every query that reaches a student record
Tenancyorg_id from the database, never the requestRead fresh on each request rather than trusted from the cookie, so suspending a school takes effect at once and a client-supplied organisation identifier — the classic tenancy bypass — is never consulted
Account statususers.status, re-read on every requestSuspension must take effect now, not when a cookie happens to expire. See the three-point enforcement below
RecoverySingle-use token, one hour, stored hashedSelf-service password reset. The response never differs between a known and an unknown address, so the form cannot be used to ask which children are registered
Suspension is enforced in three places, and each alone would leave a gap. Sign-in refuses a non-active account after the password check, with the same sentence a member of a suspended school gets — so the endpoint cannot be asked which of the two applies. withActor re-reads the status from the database on every request that matters, because a session store is a cache and a permission check that trusts a cache is not a permission check. And suspending deletes the account's open sessions, so the person stops where they are rather than continuing until their cookie lapses. Sign-in alone would leave live cookies working; session deletion alone would trust the cache.
Registration is invitation-based. Accounts are created from hashed, single-use invitation tokens — there is no open self-registration during the pilot, which keeps the user set controlled and auditable. An accepted invitation is then redacted of its name and email: the identity lives on the user row, and a second copy sitting behind an otherwise pseudonymous account is the one place a real identity would survive. The row itself stays, so a reused link still answers “already accepted” rather than “not found”.

Who can read a student's answers

The shortest true statement of the access model, and the one the impact assessment rests on: that student, and teachers at that student's own school. Nobody else.

A school administrator manages accounts and carries out erasures — the deletion dialogue counts what it destroys and shows none of it — and a platform administrator manages organisations. Neither can open a transcript or export one, and no endpoint exists that would return either. That is what makes the claim durable rather than a matter of interface design: it holds exactly as long as no such endpoint is added.

Aggregates are the exception, and are bounded on purpose. The statistics pages give both kinds of administrator counts, sums and means over groups — never a row about a person, and never a link from a figure to the student behind it. Two things keep that from eroding. Every query in db/stats.js returns a group, so there is no endpoint to misuse; and any cohort below five students is withheld rather than averaged, because an average over two students read by their own teacher is not anonymous. Withheld rows are returned with a reason rather than dropped, so a suppressed group cannot be mistaken for an empty one.

Application-level enforcement, for now. The organisation predicate lives in one module rather than being repeated in each route, because the characteristic failure of shared-schema tenancy is the query that forgot its WHERE — and here what leaks is interview transcripts written by minors. Row-level security is the intended backstop and is not yet in place; it needs a transaction or a dedicated client per request, and was deliberately separated from the scoping change so that a regression in either would be attributable. See §14.

09 — Hosting

Infrastructure & Deployment Topology

Everything runs on a single Linux host: nginx at the edge, one Node process under systemd, and PostgreSQL on the same machine over the loopback interface. No containers, no orchestration.

Internet HTTPS :443 Linux VPS — single host nginx TLS · proxy_pass client_max_body_size 10m systemd: rise.service User=deploy · Restart=always Node.js — RISE app 127.0.0.1:3002 · serves API + SPA EnvironmentFile → shared/.env PostgreSQL localhost:5432 rise database Logs /var/log/rise/app.log loopback :3002 Managed services LLM·Sheets·Email
Edge App Database Logs

Filesystem layout

  • /opt/apps/rise/current — the deployed Git checkout (working tree).
  • /opt/apps/rise/shared/.env — secrets & config, outside the repo.
  • frontend/dist — the built SPA served by Node.
  • /var/log/rise/app.log — combined stdout/stderr.

Process supervision (systemd)

  • Runs as the unprivileged deploy user; Restart=always with a 5s backoff.
  • Graceful shutdown on SIGINT with a 20s stop timeout.
  • Config injected via EnvironmentFile — the same file the deploy script sources for migrations.
Co-tenancy. RISE is designed to sit alongside other apps on a shared host without interfering: it binds only to loopback :3002, owns a single database, and is fronted by its own nginx server block.

10 — Delivery

Continuous Deployment

Deployment is driven by GitHub. Merging to the production branch through a reviewed pull request triggers a GitHub Actions workflow that builds, tests, and then releases to the server — a conventional, auditable push-to-deploy pipeline. Builds and tests run on GitHub's runners, not blind on the production host.

Merge → productionreviewed PR on GitHub GitHub Actions — CIinstall · lint / test · vite build (on GitHub runners) Deploy to hostSSH / artifact run migrations systemctl restartrise.service health check/health · rollback on fail on green build →
  1. Merge to production. Changes land on the production branch via a reviewed pull request — the single trigger for a release, with approvals and full history recorded on GitHub.
  2. CI build & test. GitHub Actions checks out the code, installs dependencies, runs linting/tests, and builds the frontend — entirely on GitHub's runners, keeping build load and secrets off the production host.
  3. Deploy to host. Only on a green build does the workflow connect to the server over SSH and deliver the release (or a pre-built artifact).
  4. Migrate. Idempotent database migrations run as part of the deploy step, before the new process takes traffic.
  5. Release & verify. The workflow restarts rise.service and probes /health; a failed check fails the deployment so a bad release is caught immediately. systemd keeps the process up with Restart=always as a safety net.
Why GitHub-driven. A push-to-production pipeline keeps every release reviewable and auditable, runs builds and tests off the production host, and gives a natural home for a staging gate, manual approval, and automated health-check rollback — the standard controls expected of a launch-grade deployment.

11 — Environments

Environments

Three environments across the engagement, sharing one codebase and configured entirely through environment variables.

EnvironmentWherePurpose
LocalDeveloper machineVite dev server + Node with live reload; local Postgres
Staging / PilotVendor-owned server (funded by Zoran Krnetić)Real students run pilot sessions against the Month-1 build
ProductionInfrastructure provided by MarabuntaLaunch environment for real operation (Month 3)
Production infrastructure ownership. Per the project specification (KA210-VET-778D8F70, Activity 5), the production hosting environment is provided by Marabunta. The vendor deploys and stabilises RISE onto that infrastructure; providing the infrastructure itself is outside the vendor's scope.

12 — Security

Security Posture

The baseline below is in place today. The launch-preparation phase extends it with GDPR-grade controls (see the platform PRD, §7.1) — encryption at rest for student-authored content, an audit log of record access, data-subject-rights endpoints, and retention automation.

In place today

  • TLS termination at nginx; app binds to loopback only.
  • bcrypt-hashed passwords; opaque, httpOnly, secure, SameSite cookies.
  • Server-side authorization with per-resource ownership checks.
  • Invitation-only registration via hashed single-use tokens.
  • Secrets in an environment file outside the repository; service-account keys git-ignored.
  • Parameterised SQL throughout (no string-built queries).
  • Request body size capped at the proxy (10 MB).

Added since — built

  • Audit log of every teacher read of a student record, holding no content and surviving the deletion of the account it describes.
  • GET /api/me/export — a person downloads their own data immediately. No administrator can export anyone else's; that endpoint was removed.
  • Erasure requests as a queue with a stored due date, and a guarded delete that counts what it destroys without showing any of it.
  • Privacy notices in the product, versioned in git, separate texts for students and staff.
  • Organisation isolation — a teacher reaches only their own school's students.
  • A DPIA (DPIA.md) and a transfer pack drafted for the three schools' advisers.
  • Database access logging, so a maintenance session at the psql level is evidenced too — the application log cannot see it.
  • Account suspension — reversible, destroys nothing, enforced at sign-in and on every request, and audited in both directions.
  • Self-service password reset — single-use hourly tokens, and a response that does not differ between a known and an unknown address.
  • A teacher's invitation cannot carry a role. Enforced by the shape of the schema rather than by validating a value: the field is simply absent, and because z.object strips what it does not declare, a body carrying "role": "admin" arrives at the handler without it. A test fails if the field is ever added — validating it would let a teacher mint an administrator of their own school with nothing on screen looking different.
  • Per-student interview limits — attempts per scenario, a total, and a per-scenario waiting period, each a platform default a school may override. A cost ceiling as much as a pedagogical one: every answer is two model calls.
  • The privacy notice is completed by the school rather than shipping with <placeholder> text visible to students — see the data model note on org_settings.

Still outstanding

  • Encryption at rest for student_answer and feedback prose. Unblocked — the scores and answer length were moved out of the JSON so they survive it — and scheduled after the infrastructure migration, so a key that must never be lost is not introduced during a database move.
  • Retention. The periods are no longer outstanding: each school now sets them itself, on the privacy notice page, and the notice states them in its own language. What is still outstanding is the deletion job that reads them — nothing expires today, and a period on that page is a published promise the platform does not yet keep on its own.
  • Scheduled backups. Designed, not built. Dumps are taken by hand before releases and at no other time — the most urgent item in the project.
  • Row-level security behind the application scoping.
  • Minors' safeguards: a feedback safety check for the model's tone. Not consent — the lawful basis is Article 6(1)(e), public task, which is what makes Article 8's digital age of consent inapplicable.

13 — Operations

Observability & Operations

Today

  • Health. GET /health returns a simple OK for uptime checks and proxy probes.
  • Logs. Combined stdout/stderr appended to /var/log/rise/app.log; deploy output visible via journalctl.
  • LLM accounting. Model and token counts recorded per AI message for cost visibility.
  • Recovery. systemd auto-restart; stateless app means a restart is always safe.

Recommended for launch

  • Log rotation for app.log (logrotate) to bound disk use.
  • Automated PostgreSQL backups with off-host retention + a tested restore.
  • External uptime monitoring on /health with alerting.
  • Post-deploy health check with automatic rollback on failure.
  • Error aggregation / structured logging as volume grows.
Backups are a launch prerequisite. The pilot can run on a single host, but production on Marabunta infrastructure must include scheduled, off-host database backups with a rehearsed restore before real cohorts depend on the data.

14 — Evolution

Scalability & Future Architecture

The current single-process design comfortably serves classroom-scale, bursty pilot usage. The modular seams mean it can grow along well-understood axes without a rewrite.

Known bottlenecks

  • Synchronous LLM calls in the request path dominate latency and hold a connection open for the duration — the single external dependency on the hot path.
  • Single Node process and a co-located database share one host's resources.

Growth path

  • Run the LLM call asynchronously (job + polling / websockets) so requests return fast.
  • Scale the stateless app horizontally behind the proxy; sessions already live in Postgres.
  • Move PostgreSQL to a managed/replicated instance; add read replicas if needed.
  • Abstract the LLM provider (Scaleway / self-hosted Qwen / alternative) behind the existing module boundary — already exercised once, when Together.ai was replaced by Scaleway with no change outside llm/llm.js.
Provider-agnostic by design. Because content, AI, email, and persistence sit behind module boundaries and nothing is tied to a proprietary cloud, RISE can move from one VPS to Marabunta's infrastructure, a cloud provider, or an on-prem school appliance with configuration changes rather than re-architecture.

15 — Options

Alternative Deployment Models

The default target is a single managed host (a VPS or the infrastructure provided by Marabunta). Because the stack is provider-agnostic and every external dependency sits behind a module boundary, two other models are fully supported without re-architecture — a fully on-premises install inside a school's own network with a locally hosted LLM, and a managed cloud deployment on AWS.

Option A — On-premises in the school network, with a locally hosted LLM

The entire platform — application, database, and the language model — runs on a machine inside the school's own network. Nothing about a student's practice ever leaves the building. This is the strongest possible privacy posture for processing minors' and SEN students' data, and it keeps working even with poor or no internet connectivity.

School network (LAN) — no data leaves the building Student & teacher devices Browser RISE app Node · nginx (local TLS) PostgreSQL content · sessions · feedback Local LLM runtime Ollama / vLLM · Qwen on-prem GPU / CPU local call Sheets optional import
Clients App Database Local LLM

Why choose it

  • Data sovereignty. Student answers and all personal data stay on school hardware — a decisive advantage for GDPR compliance and minors' protection.
  • Works offline. No dependence on internet uptime or a third-party AI provider during lessons.
  • No per-token cost. Once the hardware is in place, AI feedback has no marginal usage bill.
  • Full control. The school owns the model, the data, and the update cadence.

What it requires & how it works

  • A capable on-prem machine — ideally a modest GPU, or a strong multi-core CPU for smaller models — running an open model (e.g. Qwen) via Ollama or vLLM.
  • Only the LLM provider module changes: it points at the local model's OpenAI-compatible endpoint instead of Scaleway. The rest of the app is identical.
  • App, PostgreSQL, and nginx run on the same box (or two); content is imported once from Sheets, then the install can be fully offline.
  • Trade-offs: local IT must maintain the box and apply updates; a locally hostable model may score below a hosted frontier model, so a short quality check per language is advised.

Option B — Managed cloud deployment on AWS

For scale, high availability, and managed operations, the same application maps cleanly onto standard AWS building blocks — nothing in the code changes, only where each piece runs.

Reference mapping

  • Compute: the Node app on EC2, or containerised on ECS/Fargate behind an Application Load Balancer (TLS via ACM).
  • Database: Amazon RDS for PostgreSQL — managed backups, patching, and optional Multi-AZ failover.
  • AI: keep Scaleway, or switch to Amazon Bedrock (Qwen / other models) to keep inference inside AWS — noting that an EU region would be a requirement, not a preference.
  • Static & backups: S3 (optionally CloudFront); logs/metrics: CloudWatch; secrets: Secrets Manager / SSM.

Trade-offs

  • Pros: elastic scaling, managed DB backups and HA, first-class monitoring, easy horizontal scale of the stateless app.
  • Cons: higher and usage-based cost, more operational surface than a single VPS.
  • GDPR: pin all services to an EU region (e.g. eu-central-1) and keep student data within EU data residency.

At a glance

 Managed VPS / Marabunta defaultOn-prem + local LLMAWS cloud
Data locationVendor / partner hostInside the schoolAWS region (choose EU)
AI inferenceScaleway, Paris (hosted)Local model (Ollama / vLLM)Bedrock, EU region
Internet needed at runtimeYesNoYes
Ongoing cost profileLow, fixedHardware up-front, low runUsage-based, higher
Scaling & HAManual, single hostSingle siteManaged, elastic
Best forPilot & launchMax privacy / low connectivityLarge multi-school scale
One codebase, three targets. The same RISE application runs in all three models — the only thing that changes is configuration and which module endpoints are wired up. That portability is a deliberate architectural goal, not an afterthought.