Cadentia: Building Free Music Education, End to End
Good music education is expensive and gate-kept. Private teachers, method books, apps that lock the useful half behind a subscription — the knowledge itself isn't scarce, but access to it is. I spent years around music before I wrote code professionally, and that gap always bothered me. Cadentia is my answer to it: a browser-based platform where you learn music theory, ear training, sight-reading, and piano technique for free, practicing against automatic evaluation, with the core curriculum built on public-domain material so nothing essential is ever paywalled.
It's also a project I took from an empty repository to production entirely on my own — backend, frontend, database, infrastructure, deployment, the didactic content itself. This post is about how it's built and how it runs. I want to talk less about product features and more about the engineering underneath: the stack, the contract between the two codebases, and the infrastructure on Railway that keeps it online.
Why "Free" Is an Architecture Decision, Not a Pricing Page
Free access isn't a checkbox you flip at launch — it's a constraint that decides how you build. If the mission is that no one is priced out of learning music, then the expensive, proprietary shortcuts are off the table by design:
- No managed backend-as-a-service. No Supabase, no Firebase. Auth, data, and business logic are all application code I own, running on infrastructure I can reason about and swap out. Vendor lock-in is a slow tax, and a free project can't afford to have its economics dictated by someone else's pricing tier.
- No audio ML for evaluation. Grading a performance by listening would mean models, GPUs, and a compute bill that scales with every practice session. Instead, evaluation runs on MIDI — deterministic, cheap, and runnable in a plain Node process. Audio is capture and playback only.
- Public-domain core. The foundational curriculum is copyright-free, so the thing that makes the platform valuable can't be taken away or licensed out from under it.
Every one of those choices trades convenience for independence, which is exactly the trade a self-funded, free platform has to keep making.
The Stack
Cadentia is TypeScript from end to end, split across two independent repositories with no monorepo tying them together.
Backend — api: NestJS on Node 22, organized into domain modules (auth, content, paths, practice, economy, curation, profiles, recordings, and more), each with a clean controller → service → repository boundary. Prisma is the ORM, talking to PostgreSQL through the pg adapter. Input validation is enforced at every route with class-validator DTOs. Auth is built in-house — bcrypt, JWT with server-side refresh tokens delivered as httpOnly cookies, Google SSO layered on via google-auth-library. Helmet, a throttler for rate limits, and pino for structured logging round out the production hardening.
Frontend — web: React Router 7 in framework mode — server-first, so data loads through loaders and actions rather than a client-side global store. The UI is shadcn/ui on Tailwind with lucide icons. The genuinely domain-specific parts are the interesting ones: webmidi for keyboard input, tone for synthesis and reference playback, OpenSheetMusicDisplay for rendering scores from MusicXML, and XState for the one place that truly needs a state machine — the practice session, with its named states from idle through countdown, playing, evaluating, and result.
One Contract, Generated Not Written
The two repos never import from each other. The only thing between them is the HTTP API, and I refuse to describe that API twice. NestJS generates an OpenAPI spec from its Swagger decorators; the frontend's entire TypeScript client is generated from that spec with orval. Nobody hand-writes a fetch wrapper or a response type.
The payoff is that the contract can't silently rot. Remove an endpoint or change a response shape on the server, regenerate, and the break surfaces as a compile error on the web side instead of a runtime surprise in production. It also enforces a healthy discipline: because the client is generated, the Swagger decorators aren't documentation-after-the-fact — they are the interface. Getting the OpenAPI right is getting the frontend's types right.
One scar worth recording: the OpenAPI export step once failed silently on a TypeScript cast error and shipped a stale spec. Now the export always checks for "Found N error(s)" before it's trusted. Generated contracts are only as good as the generation step's failure modes.
The Infrastructure: Running It on Railway
The whole thing runs on Railway, chosen because it gives me container-based deploys and managed networking without the operational weight of raw cloud. Both repositories deploy independently, each from its own Dockerfile, each with its own health check and restart policy.
The API is a multi-stage Docker build — dependencies, build, then a lean production image that carries only node_modules, the compiled dist, the generated Prisma client, and the migration files. Migrations run as part of the release, not baked into the image, so a deploy is migrate deploy then boot:
# api/railway.toml
[deploy]
startCommand = "sh -c 'npx prisma migrate deploy && node dist/src/main.js'"
healthcheckPath = "/health"
healthcheckTimeout = 120
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 3
The /health endpoint (via @nestjs/terminus) gates every deploy — a container that can't answer healthy never takes traffic. Migrations are versioned from day zero and the database can be rebuilt reproducibly from an empty schema, which matters enormously for a project maintained by one person: I never have to remember what state production drifted into.
The frontend has its own subtlety. Vite inlines VITE_* variables at build time, so the API URL has to be present when the image is built, not when it runs. Railway passes service variables through as Docker build args, and the Dockerfile threads them into the build stage:
# web/Dockerfile — Vite bakes VITE_* into the bundle at build time
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
Around the two services sits the rest of the stack: PostgreSQL on Neon as the primary database, Cloudflare R2 for all binary assets (MusicXML scores, MIDI recordings, cosmetic sprites) behind an S3-compatible client, Cloudflare for DNS and CDN, and Resend for transactional email. That last one taught me a real lesson: I built email on generic SMTP first, only to discover Railway blocks outbound SMTP egress below its Pro tier — cold registrations were hanging for two minutes and timing out. I migrated to Resend's HTTP API with short timeouts, and email started working. Infrastructure constraints don't announce themselves; you meet them at 2am when a signup silently fails.
The other infrastructure scar was proxy-related. Sitting behind Railway's edge, the app was reading the edge machine's address as the client IP — which fragmented rate limiting per edge node and poisoned the audit log with wrong IPs. The fix was getting trust proxy set to the correct number of hops, verified with a temporary, env-gated middleware that logged the forwarded-for chain so I could actually see what the edge was sending. You cannot configure a proxy you can't observe.
Server Is the Source of Truth
One principle holds the whole system together and shows up everywhere: the server is authoritative, and the client is treated as untrusted. Practice is evaluated in the browser for instant feedback, but the same evaluator runs again on the server on the raw MIDI events before anything is recorded — the two implementations share convergence fixtures in CI so they can't drift apart. Progress, and the ledger-based reward economy behind it, are written only by the server. The client's job is to feel fast; the server's job is to be right.
That split is what lets a free, open platform stay coherent. Anyone can open the browser tools and send whatever they want; none of it matters, because the source of truth was never in the browser to begin with.
What Building It Solo Taught Me
Carrying a project from zero to production alone forces a kind of honesty about scope. There's no one to hand the "boring" half to, so every decision is a decision about what future-me will have to maintain. Reproducible migrations, a generated API contract, health-gated deploys, structured logs — these aren't enterprise ceremony when you're solo, they're survival. They're how one person keeps a real system running without it quietly becoming a house of cards.
And the mission kept the engineering grounded. "Free access to music education" isn't a slogan on the landing page; it's the reason there's no BaaS bill, no audio-ML compute, no paywall on the core. The architecture is the mission, made concrete.
Cadentia is live at cadentia.club. Open it, plug in a keyboard, and learn something for free.