Building a Production-Ready LMS Platform: A Complete Guide to Modern EdTech Architecture

Remember when I thought building a Learning Management System would be “just another CRUD app”? Yeah, three months and countless espresso shots later, I can tell you it’s anything but simple. But here’s the thing—it’s incredibly rewarding, and I’m about to share everything I learned so you don’t have to make the same mistakes I did.

Why Another LMS? (And Why You Should Care)

The EdTech space is exploding. With remote learning becoming the norm rather than the exception, there’s never been a better time to understand how these platforms work under the hood. Whether you’re building for a startup, your university, or just leveling up your full-stack skills, this project will teach you things bootcamps skip over.

Plus, let’s be honest—”Built a production-grade LMS with microservices architecture” looks pretty damn good on your resume.

The Tech Stack (Because Choices Matter)

After evaluating different options, here’s what I landed on and why:

Backend:

  • NestJS (TypeScript) – Because Express is great, but NestJS gives you structure without the boilerplate fatigue
  • PostgreSQL – Relational data for users, courses, and enrollments
  • Redis – Caching and session management
  • MongoDB – Flexible storage for course content and analytics
  • Socket.io – Real-time notifications and live classes
  • Bull – Job queues for video processing and email notifications

Frontend:

  • Next.js 14 with App Router – SSR, RSC, and stellar DX
  • TailwindCSS – Utility-first styling that actually makes sense
  • Zustand – State management without the Redux ceremony
  • React Query – Server state made easy
  • Shadcn/ui – Beautiful, accessible components

Infrastructure:

  • Docker & Docker Compose – Local dev environment
  • AWS S3 – Video and file storage
  • CloudFront – CDN for content delivery
  • GitHub Actions – CI/CD pipeline

System Architecture: The Big Picture

Here’s how everything fits together:

┌─────────────────────────────────────────────────────────────┐
│                         Client Layer                         │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐      │
│  │ Student App  │  │ Instructor   │  │  Admin Panel │      │
│  │  (Next.js)   │  │  Dashboard   │  │   (Next.js)  │      │
│  └──────────────┘  └──────────────┘  └──────────────┘      │
└─────────────────────────────────────────────────────────────┘
                              │
                              │ HTTPS/WSS
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                      API Gateway (NestJS)                    │
│                  Authentication & Rate Limiting              │
└─────────────────────────────────────────────────────────────┘
                              │
                ┌─────────────┼─────────────┐
                │             │             │
                ▼             ▼             ▼
┌──────────────────┐ ┌──────────────┐ ┌──────────────┐
│  Auth Service    │ │   Course     │ │   Payment    │
│   (NestJS)       │ │   Service    │ │   Service    │
│                  │ │  (NestJS)    │ │  (NestJS)    │
└──────────────────┘ └──────────────┘ └──────────────┘
         │                   │                 │
         ▼                   ▼                 ▼
┌──────────────────────────────────────────────────────────┐
│                      Data Layer                           │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌─────────┐ │
│  │PostgreSQL│  │  MongoDB │  │   Redis  │  │   S3    │ │
│  └──────────┘  └──────────┘  └──────────┘  └─────────┘ │
└──────────────────────────────────────────────────────────┘

The architecture follows domain-driven design principles. Each service owns its domain and communicates through well-defined APIs. This isn’t just for scalability—it’s about maintaining your sanity when the codebase grows.

Project Structure: Where Everything Lives

Here’s the monorepo structure I’m using. It keeps frontend and backend in one place while maintaining clear boundaries:

lms-platform/
├── apps/
│   ├── api/                          # Main NestJS API
│   │   ├── src/
│   │   │   ├── modules/
│   │   │   │   ├── auth/
│   │   │   │   │   ├── controllers/
│   │   │   │   │   │   └── auth.controller.ts
│   │   │   │   │   ├── services/
│   │   │   │   │   │   └── auth.service.ts
│   │   │   │   │   ├── dto/
│   │   │   │   │   │   ├── login.dto.ts
│   │   │   │   │   │   └── register.dto.ts
│   │   │   │   │   ├── guards/
│   │   │   │   │   │   ├── jwt-auth.guard.ts
│   │   │   │   │   │   └── roles.guard.ts
│   │   │   │   │   ├── strategies/
│   │   │   │   │   │   └── jwt.strategy.ts
│   │   │   │   │   └── auth.module.ts
│   │   │   │   │
│   │   │   │   ├── users/
│   │   │   │   │   ├── controllers/
│   │   │   │   │   │   └── users.controller.ts
│   │   │   │   │   ├── services/
│   │   │   │   │   │   └── users.service.ts
│   │   │   │   │   ├── entities/
│   │   │   │   │   │   └── user.entity.ts
│   │   │   │   │   ├── dto/
│   │   │   │   │   │   ├── create-user.dto.ts
│   │   │   │   │   │   └── update-user.dto.ts
│   │   │   │   │   └── users.module.ts
│   │   │   │   │
│   │   │   │   ├── courses/
│   │   │   │   │   ├── controllers/
│   │   │   │   │   │   ├── courses.controller.ts
│   │   │   │   │   │   └── lessons.controller.ts
│   │   │   │   │   ├── services/
│   │   │   │   │   │   ├── courses.service.ts
│   │   │   │   │   │   ├── lessons.service.ts
│   │   │   │   │   │   └── progress.service.ts
│   │   │   │   │   ├── entities/
│   │   │   │   │   │   ├── course.entity.ts
│   │   │   │   │   │   ├── lesson.entity.ts
│   │   │   │   │   │   ├── enrollment.entity.ts
│   │   │   │   │   │   └── progress.entity.ts
│   │   │   │   │   ├── dto/
│   │   │   │   │   │   ├── create-course.dto.ts
│   │   │   │   │   │   ├── update-course.dto.ts
│   │   │   │   │   │   └── enroll.dto.ts
│   │   │   │   │   └── courses.module.ts
│   │   │   │   │
│   │   │   │   ├── payments/
│   │   │   │   │   ├── controllers/
│   │   │   │   │   │   └── payments.controller.ts
│   │   │   │   │   ├── services/
│   │   │   │   │   │   ├── payments.service.ts
│   │   │   │   │   │   └── stripe.service.ts
│   │   │   │   │   ├── entities/
│   │   │   │   │   │   └── payment.entity.ts
│   │   │   │   │   └── payments.module.ts
│   │   │   │   │
│   │   │   │   ├── media/
│   │   │   │   │   ├── controllers/
│   │   │   │   │   │   └── media.controller.ts
│   │   │   │   │   ├── services/
│   │   │   │   │   │   ├── upload.service.ts
│   │   │   │   │   │   └── video-processing.service.ts
│   │   │   │   │   └── media.module.ts
│   │   │   │   │
│   │   │   │   ├── notifications/
│   │   │   │   │   ├── gateway/
│   │   │   │   │   │   └── notifications.gateway.ts
│   │   │   │   │   ├── services/
│   │   │   │   │   │   ├── notifications.service.ts
│   │   │   │   │   │   └── email.service.ts
│   │   │   │   │   └── notifications.module.ts
│   │   │   │   │
│   │   │   │   ├── analytics/
│   │   │   │   │   ├── controllers/
│   │   │   │   │   │   └── analytics.controller.ts
│   │   │   │   │   ├── services/
│   │   │   │   │   │   └── analytics.service.ts
│   │   │   │   │   └── analytics.module.ts
│   │   │   │   │
│   │   │   │   └── quizzes/
│   │   │   │       ├── controllers/
│   │   │   │       │   └── quizzes.controller.ts
│   │   │   │       ├── services/
│   │   │   │       │   └── quizzes.service.ts
│   │   │   │       ├── entities/
│   │   │   │       │   ├── quiz.entity.ts
│   │   │   │       │   ├── question.entity.ts
│   │   │   │       │   └── submission.entity.ts
│   │   │   │       └── quizzes.module.ts
│   │   │   │
│   │   │   ├── common/
│   │   │   │   ├── decorators/
│   │   │   │   │   ├── current-user.decorator.ts
│   │   │   │   │   └── roles.decorator.ts
│   │   │   │   ├── filters/
│   │   │   │   │   └── http-exception.filter.ts
│   │   │   │   ├── interceptors/
│   │   │   │   │   ├── logging.interceptor.ts
│   │   │   │   │   └── transform.interceptor.ts
│   │   │   │   ├── pipes/
│   │   │   │   │   └── validation.pipe.ts
│   │   │   │   └── middleware/
│   │   │   │       └── logger.middleware.ts
│   │   │   │
│   │   │   ├── config/
│   │   │   │   ├── database.config.ts
│   │   │   │   ├── jwt.config.ts
│   │   │   │   ├── redis.config.ts
│   │   │   │   └── aws.config.ts
│   │   │   │
│   │   │   ├── app.module.ts
│   │   │   └── main.ts
│   │   │
│   │   ├── test/
│   │   ├── nest-cli.json
│   │   ├── tsconfig.json
│   │   └── package.json
│   │
│   ├── web/                          # Student-facing Next.js app
│   │   ├── src/
│   │   │   ├── app/
│   │   │   │   ├── (auth)/
│   │   │   │   │   ├── login/
│   │   │   │   │   │   └── page.tsx
│   │   │   │   │   └── register/
│   │   │   │   │       └── page.tsx
│   │   │   │   ├── (dashboard)/
│   │   │   │   │   ├── dashboard/
│   │   │   │   │   │   └── page.tsx
│   │   │   │   │   ├── courses/
│   │   │   │   │   │   ├── page.tsx
│   │   │   │   │   │   └── [id]/
│   │   │   │   │   │       ├── page.tsx
│   │   │   │   │   │       └── lesson/[lessonId]/
│   │   │   │   │   │           └── page.tsx
│   │   │   │   │   ├── my-learning/
│   │   │   │   │   │   └── page.tsx
│   │   │   │   │   └── profile/
│   │   │   │   │       └── page.tsx
│   │   │   │   ├── layout.tsx
│   │   │   │   └── page.tsx
│   │   │   │
│   │   │   ├── components/
│   │   │   │   ├── ui/              # Shadcn components
│   │   │   │   │   ├── button.tsx
│   │   │   │   │   ├── card.tsx
│   │   │   │   │   ├── input.tsx
│   │   │   │   │   └── ...
│   │   │   │   ├── layout/
│   │   │   │   │   ├── header.tsx
│   │   │   │   │   ├── sidebar.tsx
│   │   │   │   │   └── footer.tsx
│   │   │   │   ├── course/
│   │   │   │   │   ├── course-card.tsx
│   │   │   │   │   ├── lesson-player.tsx
│   │   │   │   │   ├── course-progress.tsx
│   │   │   │   │   └── quiz-component.tsx
│   │   │   │   └── common/
│   │   │   │       ├── loading-spinner.tsx
│   │   │   │       └── error-boundary.tsx
│   │   │   │
│   │   │   ├── lib/
│   │   │   │   ├── api/
│   │   │   │   │   ├── client.ts
│   │   │   │   │   ├── courses.ts
│   │   │   │   │   ├── auth.ts
│   │   │   │   │   └── users.ts
│   │   │   │   ├── hooks/
│   │   │   │   │   ├── use-auth.ts
│   │   │   │   │   ├── use-course.ts
│   │   │   │   │   └── use-toast.ts
│   │   │   │   ├── store/
│   │   │   │   │   ├── auth-store.ts
│   │   │   │   │   └── ui-store.ts
│   │   │   │   └── utils.ts
│   │   │   │
│   │   │   └── styles/
│   │   │       └── globals.css
│   │   │
│   │   ├── public/
│   │   ├── next.config.js
│   │   ├── tailwind.config.js
│   │   └── package.json
│   │
│   └── admin/                        # Admin panel
│       ├── src/
│       │   ├── app/
│       │   │   ├── dashboard/
│       │   │   ├── users/
│       │   │   ├── courses/
│       │   │   ├── analytics/
│       │   │   └── settings/
│       │   ├── components/
│       │   └── lib/
│       └── package.json
│
├── packages/                          # Shared packages
│   ├── types/
│   │   ├── src/
│   │   │   ├── user.types.ts
│   │   │   ├── course.types.ts
│   │   │   └── index.ts
│   │   └── package.json
│   │
│   ├── ui/                           # Shared UI components
│   │   └── package.json
│   │
│   └── utils/
│       └── package.json
│
├── docker/
│   ├── api/
│   │   └── Dockerfile
│   ├── web/
│   │   └── Dockerfile
│   └── nginx/
│       └── nginx.conf
│
├── .github/
│   └── workflows/
│       ├── ci.yml
│       └── deploy.yml
│
├── docker-compose.yml
├── docker-compose.prod.yml
├── .env.example
├── .gitignore
├── package.json
└── README.md

Database Schema: The Foundation

Your data model will make or break your app. Here’s the core schema I’m using with PostgreSQL:

Users & Authentication:

users
- id (UUID, PK)
- email (unique)
- password_hash
- first_name
- last_name
- role (enum: student, instructor, admin)
- avatar_url
- is_verified
- created_at
- updated_at

user_profiles
- id (UUID, PK)
- user_id (FK  users)
- bio
- phone
- date_of_birth
- social_links (JSONB)

Courses & Content:

courses
- id (UUID, PK)
- instructor_id (FK  users)
- title
- slug (unique)
- description
- thumbnail_url
- price
- level (enum: beginner, intermediate, advanced)
- category_id (FK  categories)
- is_published
- created_at
- updated_at

lessons
- id (UUID, PK)
- course_id (FK  courses)
- title
- description
- video_url
- duration (seconds)
- order
- is_preview
- resources (JSONB)

enrollments
- id (UUID, PK)
- user_id (FK  users)
- course_id (FK  courses)
- enrolled_at
- completed_at
- progress_percentage

lesson_progress
- id (UUID, PK)
- enrollment_id (FK  enrollments)
- lesson_id (FK  lessons)
- completed
- watched_duration
- last_position

Quizzes & Assessments:

quizzes
- id (UUID, PK)
- lesson_id (FK  lessons)
- title
- passing_score
- time_limit

questions
- id (UUID, PK)
- quiz_id (FK  quizzes)
- question_text
- question_type (multiple_choice, true_false, short_answer)
- options (JSONB)
- correct_answer
- points

quiz_submissions
- id (UUID, PK)
- quiz_id (FK  quizzes)
- user_id (FK  users)
- score
- passed
- submitted_at

Key Features & Implementation

1. Authentication & Authorization

I’m using JWT with refresh tokens. The flow goes like this:

// auth.service.ts
async login(loginDto: LoginDto) {
  const user = await this.validateUser(loginDto.email, loginDto.password);

  const accessToken = this.generateAccessToken(user);
  const refreshToken = this.generateRefreshToken(user);

  await this.storeRefreshToken(user.id, refreshToken);

  return { accessToken, refreshToken, user };
}

The access token expires in 15 minutes, refresh token in 7 days. This gives you security without annoying users with constant re-logins.

2. Video Upload & Processing

This was the most challenging part. Here’s the architecture:

  1. Frontend uploads directly to S3 using presigned URLs
  2. S3 triggers Lambda function (or Bull job in our case)
  3. Background job processes video (transcoding, thumbnail generation)
  4. Updates database with processed URLs
  5. Sends notification to instructor
// video-processing.service.ts
@Process('video-processing')
async processVideo(job: Job) {
  const { videoKey, courseId } = job.data;

  // Download from S3
  const video = await this.s3.getObject(videoKey);

  // Transcode to multiple qualities
  const qualities = ['1080p', '720p', '480p'];
  const transcodedUrls = await this.transcodeVideo(video, qualities);

  // Generate thumbnail
  const thumbnailUrl = await this.generateThumbnail(video);

  // Update database
  await this.lessonsService.update(courseId, {
    videoUrls: transcodedUrls,
    thumbnailUrl
  });

  // Notify instructor
  await this.notificationsService.send(instructorId, {
    type: 'VIDEO_PROCESSED',
    message: 'Your video has been processed successfully'
  });
}

3. Real-time Features

Using Socket.io for live notifications and real-time progress updates:

// notifications.gateway.ts
@WebSocketGateway({ cors: true })
export class NotificationsGateway {
  @WebSocketServer()
  server: Server;

  @SubscribeMessage('join-course')
  handleJoinCourse(client: Socket, courseId: string) {
    client.join(`course-${courseId}`);
  }

  notifyProgress(userId: string, data: ProgressUpdate) {
    this.server.to(`user-${userId}`).emit('progress-update', data);
  }
}

4. Payment Integration

Stripe integration for course payments with webhook handling:

// stripe.service.ts
async createCheckoutSession(courseId: string, userId: string) {
  const course = await this.coursesService.findOne(courseId);

  const session = await this.stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    line_items: [{
      price_data: {
        currency: 'usd',
        product_data: {
          name: course.title,
          images: [course.thumbnailUrl]
        },
        unit_amount: course.price * 100
      },
      quantity: 1
    }],
    metadata: { courseId, userId },
    success_url: `${process.env.FRONTEND_URL}/courses/${courseId}?success=true`,
    cancel_url: `${process.env.FRONTEND_URL}/courses/${courseId}?canceled=true`
  });

  return session;
}

Frontend: The Student Experience

The UI is built around three core principles:

  1. Clarity – Students should never be confused about what to do next
  2. Speed – Everything should feel instant
  3. Progress – Constant feedback on learning progress

Design System

I’m using a clean, minimal design with these core elements:

  • Colors: Primary purple (#7C3AED), Secondary teal (#14B8A6), Neutral grays
  • Typography: Inter for UI, Space Grotesk for headings
  • Spacing: 8px base unit, consistent throughout
  • Components: Built with Shadcn/ui for consistency

Course Player Component

This is the heart of the student experience:

// lesson-player.tsx
export function LessonPlayer({ lesson, enrollment }: Props) {
  const [progress, setProgress] = useState(0);
  const videoRef = useRef<HTMLVideoElement>(null);

  const { mutate: updateProgress } = useMutation({
    mutationFn: (data: ProgressUpdate) =>
      api.lessons.updateProgress(lesson.id, data),
  });

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    const handleTimeUpdate = () => {
      const currentProgress = (video.currentTime / video.duration) * 100;
      setProgress(currentProgress);

      // Debounced progress update
      updateProgress({
        watchedDuration: video.currentTime,
        lastPosition: video.currentTime,
        completed: currentProgress > 90
      });
    };

    video.addEventListener('timeupdate', handleTimeUpdate);
    return () => video.removeEventListener('timeupdate', handleTimeUpdate);
  }, []);

  return (
    <div className="space-y-4">
      <video
        ref={videoRef}
        src={lesson.videoUrl}
        controls
        className="w-full aspect-video rounded-lg"
      />
      <ProgressBar value={progress} />
      <LessonResources resources={lesson.resources} />
    </div>
  );
}

Performance Optimization

This is where you separate hobby projects from production-ready apps.

Caching Strategy

// Redis caching for frequently accessed data
@Injectable()
export class CacheService {
  async getCachedCourse(courseId: string) {
    const cached = await this.redis.get(`course:${courseId}`);
    if (cached) return JSON.parse(cached);

    const course = await this.coursesService.findOne(courseId);
    await this.redis.setex(`course:${courseId}`, 3600, JSON.stringify(course));

    return course;
  }
}

Database Optimization

  • Proper indexing on foreign keys and frequently queried fields
  • Connection pooling (PgBouncer)
  • Query optimization with proper joins
  • Pagination for large datasets

Frontend Optimization

  • Image optimization with Next.js Image component
  • Code splitting by route
  • React Query for smart data fetching and caching
  • Lazy loading for heavy components

Security Considerations

Security isn’t optional. Here’s what’s implemented:

  1. Authentication: JWT with refresh tokens, bcrypt for password hashing
  2. Authorization: Role-based access control (RBAC)
  3. Input Validation: Class-validator for DTOs
  4. XSS Protection: Content Security Policy headers
  5. Rate Limiting: Prevent brute force attacks
  6. SQL Injection: TypeORM prevents this by default, but always use parameterized queries
  7. File Upload: Validate file types, scan for malware, use signed URLs
// Rate limiting example
@UseGuards(ThrottlerGuard)
@Throttle(5, 60) // 5 requests per minute
@Post('login')
async login(@Body() loginDto: LoginDto) {
  return this.authService.login(loginDto);
}

Deployment & DevOps

Docker Setup

# docker-compose.yml
version: '3.8'
services:
  api:
    build: ./apps/api
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://user:pass@postgres:5432/lms
      - REDIS_URL=redis://redis:6379
    depends_on:
      - postgres
      - redis

  postgres:
    image: postgres:15
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: lms
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

  web:
    build: ./apps/web
    ports:
      - "3001:3000"
    environment:
      - NEXT_PUBLIC_API_URL=http://api:3000

CI/CD Pipeline

Using GitHub Actions for automated testing and deployment:

# .github/workflows/ci.yml
name: CI/CD
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
      - run: npm install
      - run: npm test
      - run: npm run build

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to production
        run: |
          # Your deployment commands here

Lessons Learned (The Hard Way)

  1. Start with the data model – I refactored this three times. Get it right from the beginning.

  2. Don’t over-engineer early – I initially tried to build a microservices architecture. For an LMS starting out, a modular monolith is perfect.

  3. Video processing is expensive – Budget for this. AWS Media Convert costs add up fast. Consider alternatives like Mux or Cloudflare Stream.

  4. Real-time is hard – Socket.io is great but managing connections at scale requires thought. Use Redis adapter for horizontal scaling.

  5. Testing saves time – Yes, writing tests feels slow initially. But debugging production issues at 2 AM is slower.

What’s Next?

The platform is functional, but there’s always more to build. Here’s my roadmap:

  • AI-powered recommendations – Suggest courses based on learning patterns
  • Live classes – WebRTC integration for real-time instruction
  • Mobile apps – React Native for iOS and Android
  • Certificates – Auto-generated upon course completion
  • Discussion forums – Community features for peer learning
  • Advanced analytics – Detailed insights for instructors

Final Thoughts

Building an LMS isn’t just about connecting videos to a database. It’s about creating an experience that makes learning accessible, engaging, and effective. The technical challenges are real, but they’re solvable. The architecture I’ve shared here is battle-tested and ready for production.

Whether you’re building this for a client, your organization, or just for the learning experience, take your time with the foundation. Get your data model right, think about scale from day one, and always prioritize the user experience.

The code is complex, sure. But watching someone learn a new skill on a platform you built? That’s the real reward.

That’s a wrap 🎁

Now go touch some code 👨‍💻

Catch me here → LinkedIn | GitHub | YouTube

Similar Posts