Back to Projects

Building a Full-Stack LMS from Scratch: The FI Group Story

July 202525 min read

How It All Started

So I got tasked with building a Learning Management System for FI Nursing College. We're talking about a comprehensive platform which includes role-based access, user management, integrated calendars. And it needed to actually work in production with real users, not just be another portfolio piece that crashes under load.

The Stack

Here's what I ended up using:

  • Next.js for the frontend (and backend, honestly)
  • PostgreSQL for the database
  • Prisma as the ORM
  • MinIO for in-house file storage
  • NocoDB for database management
  • Google Calendar API for calendar sync
  • Nginx as the reverse proxy
  • PM2 to keep things running
  • GitHub Actions for CI/CD
  • Digital Ocean for hosting

Why These Choices?

Next.js - The Obvious Pick

Look, I could've gone with a separate React frontend and Express backend. But why make life harder? Next.js gave me:

  • SSR out of the box (hello, SEO)
  • API routes so I didn't need a separate server
  • File-based routing that actually makes sense
  • Performance optimizations I didn't have to think about

Plus, the documentation is solid and the community is huge. When you're stuck at 2 AM debugging, that matters.

PostgreSQL + Prisma = Best Friends

I went with PostgreSQL because, let's be real, you don't mess around with student data. You need ACID compliance and reliability. Could I have used MongoDB? Sure. Would I have regretted it when dealing with complex relationships between users, courses, and enrollments? Absolutely.

Prisma was a game-changer. Type-safe queries, automatic migrations, and I could actually understand what my database looked like by reading the schema file:

// Clean, simple, readable
model User {
  id        String   @id @default(uuid())
  email     String   @unique
  role      Role     @default(STUDENT)
  courses   CourseEnrollment[]
  createdAt DateTime @default(now())
}

model Course {
  id          String   @id @default(uuid())
  title       String
  description String
  enrollments CourseEnrollment[]
  assignments Assignment[]
}

No more "wait, what column was that again?"

The Features That Actually Matter

1. Role-Based Access (Because Not Everyone Should See Everything)

This was crucial. You can't have students accessing admin panels or instructors seeing other instructors' private content. I built a multi-tier system:

  • Admin: Can do everything (user management, system config, the works)
  • Instructor: Course management, grading, tracking student progress
  • Student: Access courses, submit assignments, check grades

The middleware guards every route and API endpoint. If you're not supposed to be there, you're not getting in.

2. Calendar System (Harder Than You'd Think)

Building a calendar that actually works is... an experience. Had to handle:

  • Class scheduling with conflict detection
  • Exam dates that don't overlap
  • Assignment deadlines
  • Event notifications
  • Making sure everything syncs across different user roles

Ended up building a custom solution because third-party calendar integrations were either too expensive or too limited.

3. User Management Dashboard (Admin's Best Friend)

The admin panel needed to be powerful but not overwhelming:

  • Bulk import users via CSV (because nobody wants to add 500 students manually)
  • Quick role switching
  • Activity logs (who did what and when)
  • Usage analytics

4. Google Calendar Integration (The Game Changer)

Here's where things got interesting. I wanted students to get automatic calendar updates whenever a new class or timetable was created. Not just in-app notifications - actual Google Calendar events that show up on their phones.

The OAuth2 Dance with Google

Setting this up wasn't just plugging in an API key. Had to go through Google's verification process:

  • Created a project in Google Cloud Console
  • Configured OAuth 2.0 credentials
  • Set up consent screen with proper scopes
  • Submitted for Google's app verification (this took a while)
  • Handled all the privacy policy and terms of service requirements

Permissions & Scopes

Had to be careful with what permissions I requested. Too many and Google flags you, too few and the feature doesn't work. I settled on:

  • calendar.events - Create and manage events
  • calendar.readonly - Read calendar data for conflict detection

How It Works

When an instructor creates a new class or updates the timetable:

  1. System captures the event details (date, time, subject, room)
  2. Fetches all enrolled students for that course
  3. Uses Google Calendar API to create events in their calendars
  4. Students get a notification on their phones instantly
  5. If the class is rescheduled, the calendar event updates automatically
// Simplified calendar sync logic
async function syncToGoogleCalendar(classData, students) {
  const event = {
    summary: classData.title,
    location: classData.room,
    description: classData.description,
    start: { dateTime: classData.startTime },
    end: { dateTime: classData.endTime },
    reminders: {
      useDefault: false,
      overrides: [
        { method: 'popup', minutes: 30 },
        { method: 'email', minutes: 1440 }
      ]
    }
  };

  for (const student of students) {
    if (student.googleCalendarToken) {
      await calendar.events.insert({
        calendarId: 'primary',
        auth: student.googleCalendarToken,
        resource: event
      });
    }
  }
}

The approval process from Google was tedious - had to document the use case, explain data handling, show screenshots, and wait for manual review. But once approved, it's been rock solid.

5. MinIO for In-House File Storage

Storing files was another challenge. Could've used AWS S3, but that gets expensive fast when you're dealing with course materials, assignments, and student submissions. Enter MinIO.

Why MinIO?

  • S3-compatible API (easy to switch if needed)
  • Self-hosted = full control over data
  • No storage costs beyond server space
  • Fast local network speeds
  • Great for compliance with data residency requirements

Set up MinIO as a Docker container on the same server. Created buckets for different file types:

  • course-materials - Lectures, PDFs, presentations
  • assignments - Student submissions
  • user-uploads - Profile pictures, documents
// MinIO client setup
import { Client } from 'minio';

const minioClient = new Client({
  endPoint: 'localhost',
  port: 9000,
  useSSL: false,
  accessKey: process.env.MINIO_ACCESS_KEY,
  secretKey: process.env.MINIO_SECRET_KEY
});

// Upload file
async function uploadFile(file, bucketName) {
  const fileName = `${Date.now()}-${file.name}`;
  await minioClient.putObject(
    bucketName,
    fileName,
    file.buffer,
    file.size
  );
  return fileName;
}

Storage is cheap on Digital Ocean, so this approach saved a ton compared to cloud storage providers. Plus, file access is blazing fast since it's all local.

6. NocoDB for Database Management

Here's a little secret: not everyone on the team knows SQL. The admin staff needed to make quick changes to the database sometimes, and I wasn't going to give them direct PostgreSQL access.

The NocoDB Solution

NocoDB turns your database into a spreadsheet-like interface. Think Airtable, but self-hosted and connected to your actual database.

  • Deployed as a Docker container alongside the main app
  • Connected directly to the PostgreSQL database
  • Role-based access (admins only, obviously)
  • GUI for viewing and editing data without writing SQL
# Docker compose for NocoDB
version: '3'
services:
  nocodb:
    image: nocodb/nocodb:latest
    ports:
      - "8080:8080"
    environment:
      - NC_DB=pg://postgres:5432?u=user&p=password&d=fi_lms
    restart: always
    volumes:
      - nocodb_data:/usr/app/data

volumes:
  nocodb_data:

Now when someone needs to bulk update user emails or fix a data entry mistake, they can do it through a friendly UI instead of asking me to run SQL queries. Saves time and reduces errors.

Security Considerations

Obviously, this is powerful and potentially dangerous. So:

  • Only accessible via VPN
  • Strong authentication required
  • Audit logs for all changes
  • Read-only access for most tables
  • Regular backups before any bulk operations

Deployment & Infrastructure (The Fun Part)

Nginx - The Gatekeeper

Nginx is the first thing any request hits before it reaches my Next.js app. Think of it as the bouncer at a club - checking IDs, managing the line, and keeping troublemakers out.

SSL/TLS Termination

First things first: everything runs over HTTPS. I used Let's Encrypt for free SSL certificates (because why pay for something that's free?). Nginx handles the SSL handshake, decrypts the traffic, and passes it to Next.js as plain HTTP internally. This offloads the encryption work from Node, which is honestly not great at handling SSL compared to Nginx.

The certificate auto-renews every 90 days via a cron job. I set it and haven't touched it in months. Students' login credentials, grades, and personal data all flow over encrypted connections. Non-negotiable.

Request Routing & Load Balancing

Nginx routes different types of requests to different places. Static files (images, CSS, fonts) get served directly from disk - no need to hit the Node server for those. API requests go to the Next.js app. MinIO file requests get proxied to port 9000 where MinIO is running.

When I eventually scale to multiple Node instances, Nginx will load balance between them. Right now it's one instance, but the infrastructure is ready.

Caching Static Assets

Nginx caches static files in memory. When a student loads the dashboard, images and CSS files are served instantly from Nginx's cache instead of hitting the filesystem every time. Set proper cache headers, and browsers cache them too. Less bandwidth, faster loads, happy users.

Rate Limiting

This is the anti-abuse layer. Nginx tracks how many requests come from each IP address. If someone (or something) starts hammering the server - maybe trying to brute force login, or scrape data, or just being malicious - Nginx shuts them down automatically.

I configured it to allow 10 requests per second per IP. Normal users never hit that. Bots and attackers do. They get a 429 error and a timeout. Simple, effective protection that costs zero compute.

PM2 - Because Apps Crash

Node.js apps crash. It's not a question of if, it's when. An unhandled exception, a memory leak that finally maxes out, a database connection that hangs - something will eventually go wrong.

PM2 is my insurance policy. It's a process manager that keeps the Node app running no matter what.

Automatic Restarts

If the Node process crashes for any reason, PM2 detects it within milliseconds and restarts it. The downtime is usually under a second. Most users don't even notice. Without PM2, the site would just be down until I manually SSH in and restart it. At 3 AM? No thanks.

Zero-Downtime Deployments

Here's the cool part: when I deploy new code, PM2 can reload the app without dropping a single request. It starts a new instance of the app with the updated code, waits for it to be ready, then gradually shifts traffic from the old instance to the new one. Once all traffic is moved, it kills the old instance.

Students can be using the site during a deployment and never know it happened. No maintenance windows, no downtime announcements. I've deployed during peak hours without issues.

Monitoring & Logs

PM2 gives me a dashboard showing memory usage, CPU usage, uptime, and restart counts. If memory starts climbing (potential leak), I see it. If the app is restarting frequently (something's broken), I know immediately.

Logs are centralized and rotated automatically. I can stream them in real-time or search historical logs. When something breaks, I'm not digging through scattered log files across the server.

Clustering

PM2 can run multiple instances of the app in cluster mode, utilizing all CPU cores. My Digital Ocean droplet has 4 cores, so PM2 runs 4 instances and load balances between them. Better performance, better reliability (if one instance crashes, the other three keep serving traffic).

GitHub Actions - Deploy While You Sleep

I'm lazy about manual deployments. They're error-prone, time-consuming, and I hate SSHing into servers to run commands. So I automated everything.

The Workflow

When I push code to the main branch on GitHub, a chain reaction starts:

Step 1: GitHub Actions Triggers

GitHub detects the push and spins up a Ubuntu container in their cloud. Free CI/CD minutes are generous for private repos, so this costs me nothing.

Step 2: Tests Run

First, it installs dependencies and runs the test suite. Unit tests, integration tests, the works. If anything fails, the deployment stops immediately. I get a notification, and the broken code never touches production.

This has saved me multiple times from deploying bugs that would've broken the site.

Step 3: Build

If tests pass, it runs npm run build. Next.js compiles everything, optimizes assets, and generates the production bundle. If the build fails (TypeScript errors, missing dependencies, whatever), deployment stops.

Step 4: Database Migrations

Before deploying new code, it runs Prisma migrations against the production database. If I added a new table or modified a schema, those changes get applied automatically. Migrations are idempotent, so running them multiple times is safe.

This happens before code deployment, so the new code always has the database schema it expects.

Step 5: SSH & Deploy

GitHub Actions SSHs into my Digital Ocean server using a private key stored in GitHub Secrets (encrypted, obviously). Once connected, it:

  • Pulls the latest code from GitHub
  • Installs any new dependencies
  • Runs the build on the server (sometimes I do this in CI instead, depends on my mood)
  • Tells PM2 to reload the app

Step 6: Verification

After deployment, the workflow makes a health check request to the site. If it gets a 200 OK response, deployment succeeded. If not, it can automatically roll back (though I haven't needed this yet).

Why This Matters

I can fix a bug, push the code from my laptop (or phone via GitHub's mobile app), and it's live in production within 5 minutes. No manual steps, no room for human error. I've deployed from coffee shops, airports, even once from a moving train.

The confidence this gives me is huge. I'm not afraid to deploy because I know the process is reliable and reversible.

The Problems I Ran Into (And How I Fixed Them)

Problem 1: Permission Hell

The Issue: Managing permissions got complicated fast. At first, I thought it'd be simple - just "admin" and "student" roles. But reality hit hard.

An instructor should be able to grade assignments for their courses, but not see other instructors' courses. They can view enrolled students, but can't delete accounts. They can upload course materials, but can't modify system settings. The admin needs full control, but even within that, some operations are more dangerous than others.

I started with a bunch of if-statements scattered throughout the code. It got messy fast. Checking permissions in every component and every API route. I'd miss checks, introduce bugs, and spend hours debugging "why can this student see the admin panel?"

The Fix: I built a middleware-based permission system. Every API route goes through authentication middleware first, then authorization middleware. The middleware checks the user's role against the required role for that route.

On the frontend, I created wrapper components that conditionally render based on permissions. Instead of sprinkling permission checks everywhere, I wrap features:

<RequireRole role="admin"> ... admin stuff ... </RequireRole>

Clean, reusable, and I can audit permissions by just looking at the route definitions. No more hunting through thousands of lines of code.

Problem 2: Zero-Downtime Migrations

The Issue: Students use this LMS 24/7. There's no "maintenance window" where I can take the site down. But I need to update the database schema as features evolve - add tables, modify columns, create relationships.

Early on, I'd just run migrations on the production database and hope for the best. This worked until it didn't. One migration locked a table during a write-heavy period, and the site hung for 30 seconds. Users saw errors. Some submissions were lost. Not great.

The Fix - A Process:

1. Always Backup First

Before every migration, I take a database snapshot. Digital Ocean makes this a one-click operation. If something goes catastrophically wrong, I can restore to 5 minutes ago. I learned this the hard way after a migration once corrupted some data (my fault, not Prisma's).

2. Test in Staging

I have a staging environment that mirrors production. Same database structure, same volume of data (anonymized copies of production data). I run migrations there first. If they take 10 seconds, I know production will be similar. If they lock tables, I see it before it affects users.

3. Use Prisma's Migration Preview

Prisma can show you the SQL it will run before actually running it. I review this every time. Sometimes it reveals inefficient operations or potential issues. I've caught destructive migrations this way.

4. Document Rollback Steps

Before running a migration, I write down how to undo it. If I'm adding a column, I document the DROP COLUMN command. If I'm changing a constraint, I know how to revert it. This has saved me twice when migrations had unexpected side effects.

5. Blue-Green Deployments for Major Changes

For really big schema changes, I use a blue-green strategy. I create the new schema alongside the old, deploy code that writes to both, wait for data to sync, then switch reads to the new schema. Only once everything is stable do I remove the old schema. More work, but zero risk.

No more "sorry for the downtime" emails. Migrations run during peak hours and nobody notices.

Making It Fast

Performance isn't a feature - it's a requirement. When you have real users trying to submit assignments before a deadline, or instructors grading during office hours, slow = broken. Here's how I made this thing fast:

Image Optimization

Next.js Image component is genuinely magic. It automatically converts images to modern formats (WebP, AVIF), generates multiple sizes, and lazy loads them. An instructor uploads a 5MB photo? Next.js serves it as a 50KB WebP file at the exact size needed for the viewport.

The lazy loading means images below the fold don't load until you scroll near them. On a page with 50 profile pictures, you only load the 10 visible ones initially. The rest load as needed. Massive bandwidth savings.

Code Splitting

Why should students download the admin panel code? They never see it. I used dynamic imports to split code by role and feature. The student bundle is 200KB. The admin bundle is 450KB. But students never download that extra 250KB because their browser never requests it.

Heavy components like the rich text editor for announcements? Dynamic import. Chart library for analytics? Dynamic import. Students on mobile with limited data plans thank me for this.

Database Connection Pooling

Opening a database connection is expensive - TLS handshake, authentication, all that overhead. Early on, I was opening a new connection for every request. Under load, this was a disaster.

Prisma has built-in connection pooling. It maintains a pool of open connections and reuses them across requests. Configured properly (pool size matching my database's connection limit), this eliminated connection overhead entirely. Requests are faster, the database is happier, everyone wins.

CDN for Static Assets

Images, CSS, JavaScript bundles - none of that needs to come from my server. I use Cloudflare's CDN (free tier is generous). Static files get cached at edge locations around the world.

A student in California and a student in Mumbai both get fast load times because they're fetching assets from nearby edge servers, not my single server in New York. First request might be slow, but it caches, and subsequent requests are lightning fast.

Compression

Nginx compresses all responses with Gzip (and Brotli for browsers that support it). HTML, CSS, JavaScript, JSON - all compressed before sending over the network. A 500KB HTML page becomes 100KB compressed. That's an 80% reduction in transfer time.

Setup took 5 minutes, benefits are permanent. Free speed boost with zero code changes.

Security (Because I Don't Want to Get Hacked)

Building a system that handles student data means security isn't optional. One breach and I'm done. Here's how I locked things down:

JWT Sessions

I use JWTs for authentication. When you log in, you get a signed token that contains your user ID and role. This token goes in an HTTP-only cookie (so JavaScript can't access it - XSS protection).

The token is signed with a secret key. Any tampering invalidates the signature. Try to change your role from "student" to "admin" in the token? The signature won't match, and you get kicked out. Simple, stateless, and secure.

SQL Injection Prevention

This is where Prisma really shines. It parameterizes every query automatically. I never write raw SQL strings with user input concatenated in. Ever.

Even if I wanted to be vulnerable to SQL injection, Prisma makes it hard. The TypeScript API just doesn't allow it. User input goes through proper escaping and parameterization. Classic attacks like ' OR '1'='1 just get treated as literal strings.

XSS Protection

React escapes content by default, which helps. But I also sanitize user input on the backend before storing it. Rich text from instructors? Sanitized through a whitelist-based HTML sanitizer. Only safe tags are allowed.

Content Security Policy headers tell browsers what scripts are allowed to run. Only scripts from my domain, no inline scripts, no eval(). If someone somehow injects a script tag, the browser refuses to execute it.

CSRF Protection

Every state-changing request (POST, PUT, DELETE) requires a CSRF token. The token is generated server-side, embedded in the page, and verified on submission. An attacker can't make your browser submit a malicious form to my site because they don't have your valid CSRF token.

Same-site cookies provide additional protection. Even if an attacker tricks you into clicking a malicious link, your authentication cookie won't be sent with that cross-site request.

Rate Limiting

Nginx rate limits are the first layer. But I also have application-level rate limiting on sensitive endpoints. Login attempts? 5 tries per IP per 15 minutes. Password reset requests? 3 per hour. API endpoints? Depends on the endpoint, but all are limited.

This stops brute force attacks, credential stuffing, and API abuse. Legitimate users never hit these limits. Attackers do, and they get blocked.

HTTPS Everywhere

Every single request goes over HTTPS. I redirect HTTP to HTTPS automatically. Browsers see the site as secure (that green padlock). Student credentials, grades, personal info - all encrypted in transit.

Let's Encrypt makes this free and automatic. There's literally no reason not to use HTTPS in 2025.

The Numbers

After months of building and refining:

  • 1000+ active users across roles
  • 99.9% uptime (the 0.1% was my fault - bad deployment on a Friday)
  • <2s average page load
  • Zero security breaches (knock on wood)
  • 60% reduction in admin workload

The college staff actually uses it daily, which is the real success metric.

What I Learned

1. Don't Overthink the Stack

I started simple: Next.js + PostgreSQL + Prisma. That's it. No microservices architecture, no Kubernetes cluster, no message queues, no event-driven this or that.

You know what? That simple stack has handled 1000+ users without breaking a sweat. I see people planning for "scale" when they have zero users. They build distributed systems for problems they don't have yet.

A well-built monolith will take you further than you think. When I actually need to scale (if that day comes), I can refactor. But premature optimization is real, and it kills projects. Start simple, add complexity only when you need it.

The tech industry has this obsession with "modern" and "scalable" architectures. But you know what's modern? Shipping a working product that users love. Use boring technology that works.

2. Automate or Regret It

I set up CI/CD on day one. Not week two, not after launch. Day. One.

Here's why: manual deployments are hell. You SSH into a server, pull code, run builds, restart services, cross your fingers. You do this once? Fine. You do this 50 times? You will mess it up. You will forget a step. You will deploy broken code at 11 PM and spend two hours debugging.

I've been there. I once manually deployed to production and forgot to run migrations. The site crashed. Users saw errors. I looked like an idiot. Never again.

Now? Push to GitHub, and it's live in 5 minutes. Tests run automatically. Migrations apply automatically. If something fails, the deployment stops. I deploy multiple times a day without thinking about it.

Setting up GitHub Actions took me maybe 2 hours. That investment has saved me dozens of hours and countless headaches. Automate the boring stuff so you can focus on building features.

3. Monitor Everything

Errors happen. Bugs slip through. The question is: do you find out when it happens, or when a user emails you three days later?

I use Sentry for error tracking. Every unhandled exception, every failed API call, every weird edge case - Sentry catches it and sends me a notification. I see the error message, the stack trace, the user's browser, the request that caused it. Everything I need to debug.

This has saved me multiple times. A student reported "something's broken" (thanks, very helpful). I checked Sentry, saw an exception in the assignment submission handler, found the bug, fixed it, and deployed - all in 20 minutes. Without monitoring? I'd still be asking "can you describe what you were doing when it broke?"

I also monitor performance. Slow database queries get logged. API endpoints with high response times get flagged. If something starts degrading, I know before users complain.

Monitoring isn't paranoia. It's professionalism. You can't fix what you don't know is broken.

4. Plan for Growth

I said don't over-engineer, and I meant it. But there's a difference between over-engineering and planning for obvious growth.

Database indexes aren't over-engineering. They're essential. Even with 10 users, indexed queries are fast. With 1000 users, they're still fast. Without indexes? Good luck.

Pagination isn't over-engineering. Even if you only have 20 items now, you'll have 200 eventually. Implement pagination from the start. It's not hard, and future-you will be grateful.

Proper error handling isn't over-engineering. Log errors, handle edge cases, validate inputs. This isn't "premature optimization" - it's basic software engineering.

The trick is knowing the difference. Microservices for a simple CRUD app? Over-engineering. Database indexes and pagination? Basic planning. One wastes time now and solves nonexistent problems. The other saves time later when problems are real.

5. Security Isn't Optional

Build security in from day one. Not "I'll add authentication later." Not "I'll worry about SQL injection after launch." From. Day. One.

Here's why: retrofitting security is a nightmare. You have to audit every endpoint, every query, every input field. You'll miss things. And when you miss things in security, people's data gets exposed.

Starting with security is easy. Use an ORM that prevents SQL injection. Use a framework with built-in CSRF protection. Hash passwords with bcrypt. Set secure headers. These aren't hard - they're just non-negotiable.

I'm handling student data - grades, personal information, attendance records. One breach and I'm done. The college is done. Careers are damaged. So I don't cut corners on security. Ever.

The best part? Most security practices make your code better anyway. Input validation prevents bugs. Proper authentication improves UX. Rate limiting keeps your site reliable. Security isn't a burden - it's a foundation.

What's Next?

Things I want to add:

  • Real-time notifications (WebSockets)
  • Mobile app (probably React Native)
  • AI-powered analytics for student performance
  • Video conferencing integration
  • Better reporting dashboard
  • Multi-language support

Wrapping Up

Building this LMS was one of the most challenging projects I've worked on. Not because the tech was complicated, but because it had to work reliably for real people every single day. No excuses, no "it works on my machine."

The combo of Next.js, PostgreSQL, and solid DevOps practices created something that actually solves problems. And honestly, that's what matters in my opinion.

Got questions about any part of this? The code patterns, deployment setup, or architecture decisions? Feel free to reach out. Always happy to talk shop.

Next.jsPostgreSQLPrismaGoogle Calendar APIMinIONocoDBDockerNginxPM2GitHub ActionsDigital Ocean

Status: Live in Production at finursingcollege.in

Users: 500+