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:
- Frontend uploads directly to S3 using presigned URLs
- S3 triggers Lambda function (or Bull job in our case)
- Background job processes video (transcoding, thumbnail generation)
- Updates database with processed URLs
- 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:
- Clarity – Students should never be confused about what to do next
- Speed – Everything should feel instant
- 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:
- Authentication: JWT with refresh tokens, bcrypt for password hashing
- Authorization: Role-based access control (RBAC)
- Input Validation: Class-validator for DTOs
- XSS Protection: Content Security Policy headers
- Rate Limiting: Prevent brute force attacks
- SQL Injection: TypeORM prevents this by default, but always use parameterized queries
- 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)
-
Start with the data model – I refactored this three times. Get it right from the beginning.
-
Don’t over-engineer early – I initially tried to build a microservices architecture. For an LMS starting out, a modular monolith is perfect.
-
Video processing is expensive – Budget for this. AWS Media Convert costs add up fast. Consider alternatives like Mux or Cloudflare Stream.
-
Real-time is hard – Socket.io is great but managing connections at scale requires thought. Use Redis adapter for horizontal scaling.
-
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 👨💻