
A tutoring center serving 200+ students operated through messaging apps, spreadsheets, and email — fragmented across teachers, students, and parents. Course materials lived in chat threads. Test results were tracked manually. Schedules were a moving target. The school needed a single source of truth — but no off-the-shelf system covered the unique workflows of a tutoring business.
A full-stack platform built end to end. Java 21 and Spring Boot 3.5 on the backend: JWT authentication, PostgreSQL 16 under Flyway, S3-compatible RustFS for files, jsoup sanitising every piece of user-submitted HTML. React 18 with Vite and Tailwind 4 on the front, TipTap for content editing, FullCalendar and drag-and-drop for the timetable. It runs on one VPS behind nginx — Docker Compose, TLS from certbot, rate limits, security headers, Prometheus for monitoring, and nightly database backups.
Lines of code
Java backend, React frontend
Database tables
20 Flyway migrations
REST controllers
Auth, courses, quizzes, chat
In production
Commissioned, in daily use

JWT with refresh-token rotation, email verification and password-setup links, and three roles — student, teacher, admin — enforced across 20 REST controllers. No third-party identity provider.
An S3-compatible RustFS instance keeps course materials, quiz images, chat attachments, news media and programme PDFs in four separate buckets. It replaced MinIO in October 2026 without a single change to the backend code. Files never leave the customer's own server, and there is no storage bill.
After every build a Playwright job crawls each public route and stores a static snapshot that nginx serves first — to everyone, so content is painted before any JavaScript runs. Search bots additionally get pages rendered straight from the database, split by User-Agent inside nginx.
Live in Production
The platform has been running at cp-binom.ru since April 2026 and is used daily by the centre's students and teachers. The portal itself is behind a login; the public part is open to anyone.

Happy to walk through the decisions behind it — why self-hosted storage, why two rendering paths for the same content, and what I would do differently next time.