Build a National EdTech Platform
Orchestrate 15 containers into a federated education platform with SSO and monitoring.
Introduction
30 Second Summary
India built a single education platform that has delivered 5.3 billion learning sessions across 36 states and 30 languages. It runs on open-source microservices. The architectural pattern behind it is something you can reproduce on your own laptop.
In this project, you will build a federated education platform by orchestrating 15 Docker containers into a single stack. You will wire together an LMS, live video classrooms, a video-on-demand library, single sign-on, edge caching, and a real-time monitoring dashboard, all accessible through one URL.
What You'll Build
A lab-scale national education platform that mirrors the architecture of India's DIKSHA system, using equivalent open-source components orchestrated with Docker Compose and monitored in real time with Prometheus and Grafana.
By the end of this project, you'll have:
- A running 15-container Docker Compose stack with Moodle, Jitsi Meet, MediaCMS, Keycloak, NGINX, and a full monitoring layer, all reachable through a single NGINX entry point.
- A working single sign-on flow where one Keycloak login grants access to the LMS, a live Jitsi classroom embedded inside a Moodle course, and a video-on-demand library on MediaCMS.
- A live Grafana dashboard showing real-time CPU, memory, and network metrics for every container, plus a validation report with five pass/fail benchmarks.
- A Secret Mission to configure multi-state identity federation and build a production readiness scorecard comparing your platform to DIKSHA's Sunbird architecture.
Are there any prerequisites?
This project assumes mid-level Docker Compose experience. You should have run multi-container stacks before and be comfortable editing YAML config files. A basic understanding of web authentication (what a login redirect is) is helpful but not required.
Verify Your Environment and Scaffold the Project
Before building a 15-container education platform, you need to confirm that every tool is installed and ready. A single missing dependency or under-resourced Docker engine can stall the entire stack later.
In this step, you will verify your local environment, scaffold the full project directory, and prepare the Jitsi Meet configuration files that the Docker Compose stack needs at launch.
In this step, get ready to:
- Run a verification script that checks Docker Desktop, Docker Compose, Git, and Node.js versions.
- Scaffold the full project directory tree with placeholder files for every service.
- Download and configure the Jitsi Docker release with generated passwords.
Run the environment verification script
This project depends on four tools: Docker Desktop (which includes Docker Compose), Git, and Node.js. Each one needs to meet a minimum version for the full stack to work.
- Open Cursor by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- Open Cursor Composer in agent mode by pressing Cmd+I (macOS) or Ctrl+I (Windows).
- Make sure the mode dropdown at the top of Composer is set to Agent.
- Paste the following prompt into Composer to generate a PowerShell verification script:
Generate a PowerShell script called verify-env.ps1 that checks my environment for this project.
Check these tools and minimum versions:
- Docker Desktop v24+ (run: docker --version)
- Docker Compose v2.20+ (run: docker compose version --short)
- Git (any version, run: git --version)
- Node.js v20+ (run: node --version)
For each tool:
1. Run the version command
2. Parse the version number
3. Compare against the minimum
4. Print a PASS/FAIL line with the detected version
If Node.js is missing or below v20, print the install command:
winget install -e --id OpenJS.NodeJS.LTS
At the end, print a summary: how many passed, how many failed.
If any failed, exit with code 1.
What does this prompt do?
This prompt asks Cursor's AI agent to generate a complete PowerShell script that automates version checking. Instead of manually running four separate commands, the script runs them all and gives you a clear PASS/FAIL report.
- Review the generated script and let Cursor save it to your working directory.
- Open a terminal in Cursor by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Run the verification script by entering this command:
./verify-env.ps1
You should see a PASS line for each tool with its detected version number.
All checks passed
All four tools are installed and meet the minimum versions. Move on to the next substep.
Node.js is missing or too old
The script prints the install command for you. Install Node.js by running:
winget install -e --id OpenJS.NodeJS.LTS
After installation completes, close and reopen your terminal, then re-run the verification script to confirm Node.js now passes.
Why Node.js?
Node.js is not part of the Docker stack itself. You need it to run Pa11y CI, the accessibility testing tool that audits your platform against WCAG 2.1 AA standards in a later step.
Another tool is missing
If Docker Desktop or Git failed, you need to install them before continuing. Docker Desktop includes Docker Compose, so installing Docker Desktop covers both checks.
- Download Docker Desktop from docker.com/products/docker-desktop.
- Download Git from git-scm.com/downloads.
After installing, close and reopen your terminal, then re-run verify-env.ps1.
Script won't run?
- If PowerShell says "cannot be loaded because running scripts is disabled," run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned and try again.
- If docker --version fails, make sure Docker Desktop is running (check your system tray).
Still stuck? Help me troubleshoot my environment verification script.
Verify Docker Desktop memory allocation
The full stack runs 15+ containers simultaneously, including databases, video transcoding workers, and a monitoring stack. Docker Desktop needs at least 14 GB of memory allocated to handle this without containers crashing.
- Open Docker Desktop by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Docker Desktop, and pressing Enter.
- Click the gear icon in the top-right corner to open Settings.
- Select Resources in the left sidebar.
- Check the Memory slider.
Memory is 14 GB or higher
You are all set. No changes needed.
Memory is below 14 GB
- Drag the Memory slider to at least 14 GB.
- Click Apply & restart.
Docker Desktop restarts with the new memory limit. This takes about 30 seconds.
What if I don't see a memory slider?
On Windows with WSL 2, Docker Desktop may manage resources through WSL instead of showing a slider. In that case, create or edit the file C:\Users\<YourUsername>\.wslconfig and add:
[wsl2]
memory=14GB
- Save the file, then restart WSL by running wsl --shutdown in PowerShell.
- Reopen Docker Desktop.
Your machine needs enough physical RAM
If your machine has less than 16 GB of total RAM, you may experience slowdowns when the full stack is running. The project still works, but container startup takes longer and video transcoding may be slow.
Scaffold the project directory
Every service in the stack needs its own configuration directory. You will use Cursor Composer to generate the entire directory tree and placeholder files in one shot.
- In your terminal, move to your Desktop by running this command:
cd ~/Desktop
- Open Cursor Composer again (Cmd+I / Ctrl+I) and paste this prompt:
Create a project directory called edtech-platform/ on my Desktop with this structure. Create all directories and placeholder files:
edtech-platform/
docker-compose.yml
nginx/
nginx.conf
keycloak/
moodle/
mediacms/
jitsi/
monitoring/
prometheus.yml
grafana/
provisioning/
datasources/
prometheus.yml
pa11y/
config.json
docs/
architecture.md
validation/
validation-results.md
For each file, add a comment or placeholder text indicating its purpose:
- docker-compose.yml: "# EdTech Platform - Docker Compose orchestration"
- nginx/nginx.conf: "# NGINX reverse proxy configuration"
- monitoring/prometheus.yml: "# Prometheus scrape configuration"
- monitoring/grafana/provisioning/datasources/prometheus.yml: "# Grafana datasource auto-provisioning"
- pa11y/config.json: empty JSON object {}
- docs/architecture.md: "# Architecture Documentation"
- validation/validation-results.md: "# Validation Results"
What does this directory structure represent?
- nginx/ holds the reverse proxy config that routes all traffic to the right service.
- keycloak/ and moodle/ store identity and LMS configuration.
- jitsi/ will hold the Jitsi environment file with security passwords.
- mediacms/ is for the video-on-demand platform settings.
- monitoring/ contains Prometheus and Grafana configuration for the observability stack.
- pa11y/ holds the accessibility audit configuration.
- docs/ and validation/ are where your architecture documentation and benchmark results go.
- Let Cursor create all the files and directories.
- Confirm the structure exists by running:
ls edtech-platform/
You should see docker-compose.yml, nginx, keycloak, moodle, mediacms, jitsi, monitoring, pa11y, docs, and validation listed in the output.
Don't see the expected folders?
- Make sure Cursor Composer actually ran the commands (check the Composer output for errors).
- Verify you are on your Desktop by running pwd (macOS/Linux) or Get-Location (PowerShell).
Need more help? Help me figure out why my project directory wasn't created.
- Open the edtech-platform folder in Cursor by running:
cd edtech-platform
Download and configure the Jitsi Docker release
Jitsi Meet ships as a set of Docker images, but it requires an environment file (.env) with security passwords before the containers can start. The official setup uses a bash script called gen-passwords.sh that does not run natively on Windows.
You will use Cursor Composer to generate a PowerShell-compatible alternative that downloads the release, extracts the environment template, and generates all the required passwords.
- Open Cursor Composer (Cmd+I / Ctrl+I) and paste this prompt:
Generate a PowerShell script called setup-jitsi.ps1 that does the following:
1. Download the Jitsi Docker stable release zip from GitHub:
URL: https://github.com/jitsi/docker-jitsi-meet/archive/refs/tags/stable-10888.zip
2. Extract the zip file.
3. Copy the env.example file from the extracted folder into my jitsi/ directory as jitsi/.env
4. In jitsi/.env, find and replace the password placeholders in the security section with strong random passwords. The passwords that need to be set are:
- JICOFO_AUTH_PASSWORD
- JVB_AUTH_PASSWORD
- JIGASI_XMPP_PASSWORD
- JIBRI_RECORDER_PASSWORD
- JIBRI_XMPP_PASSWORD
For each one, generate a 32-character random hex string using:
-join ((1..32) | ForEach-Object { '{0:x}' -f (Get-Random -Max 16) })
5. Also set these values in jitsi/.env:
- DISABLE_HTTPS=1
- HTTP_PORT=8000
- HTTPS_PORT=8443
- PUBLIC_URL=http://localhost/meet
6. Clean up the downloaded zip and extracted folder.
7. Print a summary showing that jitsi/.env was created and passwords were set.
What are these Jitsi passwords for?
Jitsi Meet uses several internal XMPP accounts for communication between its components. JICOFO_AUTH_PASSWORD authenticates the conference focus component. JVB_AUTH_PASSWORD authenticates the video bridge. Without these passwords, the Jitsi containers refuse to start.
Why disable HTTPS?
You are running Jitsi behind an NGINX reverse proxy on localhost. NGINX handles the external-facing traffic, so Jitsi only needs to serve HTTP internally on port 8000. This avoids self-signed certificate errors.
- Let Cursor create the script, then run it:
./setup-jitsi.ps1
- Verify the Jitsi environment file was created by running:
ls jitsi/
You should see .env listed in the output.
- Confirm the passwords were set by checking one of them:
Select-String 'JICOFO_AUTH_PASSWORD' jitsi/.env
You should see a line like JICOFO_AUTH_PASSWORD= followed by a long hex string, not an empty value.
Download failed or .env is empty?
- If the download fails, check your internet connection and verify the URL is reachable by opening https://github.com/jitsi/docker-jitsi-meet/releases/tag/stable-10888 in your browser.
- If JICOFO_AUTH_PASSWORD is empty, the password replacement step may have failed. Open jitsi/.env in Cursor and search for the password fields to check their values.
Still stuck? Help me troubleshoot my Jitsi setup script.
Your environment is verified, your project is scaffolded, and Jitsi is configured. Next up, you will build the complete Docker Compose file and launch all 15 containers.
Build and Launch the Docker Compose Stack
In Step 1, you scaffolded the project directory and verified your tools. Now it is time to bring the entire platform to life.
Your docker-compose.yml is the single file that defines every service in the stack. You will use Cursor Composer to generate it, configure NGINX as the front door, and then launch all 15 containers with one command.
In this step, get ready to:
- Generate the complete docker-compose.yml with all 15 service definitions.
- Generate the nginx.conf reverse proxy configuration with upstream routing and WebSocket support.
- Launch the stack and validate every service is reachable through NGINX.
Generate the Docker Compose file
The docker-compose.yml defines every container, its image, environment variables, health checks, volumes, and network connections. Cursor Composer can generate the entire file from a detailed prompt describing all 15 services.
- Open Cursor Composer in agent mode.
- Send the following prompt to generate the complete docker-compose.yml:
Generate a docker-compose.yml in the edtech-platform/ directory that defines all services on one bridge network called edtech-net.
Services:
1. nginx - image: nginx:1.27, ports 80:80 and 443:443, depends on all application services, mounts ./nginx/nginx.conf to /etc/nginx/nginx.conf:ro
2. moodle-nginx - from netresearch/moodle-docker, the nginx service (image built from their docker/nginx/ directory), no host ports (NGINX front door handles routing)
3. moodle-php - from netresearch/moodle-docker, the PHP-FPM 8.4 service, env vars: MOODLE_VERSION=5.1.2, MOODLE_URL=http://localhost/lms, DB_PASSWORD=moodlepass, DB_ROOT_PASSWORD=rootpass, VALKEY_PASSWORD=valkeypass, depends on moodle-db (healthy)
4. moodle-db - image: mariadb:11.8, env vars for root password and moodle user/db, healthcheck using mariadb-admin ping, named volume moodle_db_data
5. moodle-valkey - image: valkey/valkey:9, healthcheck using valkey-cli ping
6. keycloak - image: quay.io/keycloak/keycloak:26.6.0, command: start-dev, env vars: KC_BOOTSTRAP_ADMIN_USERNAME=admin, KC_BOOTSTRAP_ADMIN_PASSWORD=admin, KC_PROXY_HEADERS=xforwarded, KC_HTTP_RELATIVE_PATH=/auth, depends on keycloak-db (healthy), port 8080 exposed internally only
7. keycloak-db - image: postgres:17.2-alpine, env vars: POSTGRES_DB=keycloak, POSTGRES_USER=keycloak, POSTGRES_PASSWORD=keycloakpass, healthcheck using pg_isready, named volume keycloak_db_data
8. jitsi-web - image: jitsi/web:stable-10888, env vars from jitsi/.env file, HTTP_PORT=8000, HTTPS_PORT=8443, DISABLE_HTTPS=1
9. jitsi-prosody - image: jitsi/prosody:stable-10888, env vars from jitsi/.env
10. jitsi-jicofo - image: jitsi/jicofo:stable-10888, env vars from jitsi/.env
11. jitsi-jvb - image: jitsi/jvb:stable-10888, port 10000:10000/udp, env vars from jitsi/.env
12. mediacms-web - image: mediacms/mediacms:latest, no host ports (NGINX routes to it), env vars to disable migrations and celery, depends on mediacms-migrations
13. mediacms-migrations - image: mediacms/mediacms:latest, command: ./deploy/docker/prestart.sh, env vars: ADMIN_USER=admin, ADMIN_EMAIL=admin@localhost, ADMIN_PASSWORD=admin123, disable UWSGI/NGINX/CELERY, depends on mediacms-db (healthy) and mediacms-redis (healthy), restart: on-failure
14. mediacms-celery-worker - image: mediacms/mediacms:latest, disable UWSGI/NGINX/CELERY_BEAT/MIGRATIONS, depends on mediacms-migrations
15. mediacms-celery-beat - image: mediacms/mediacms:latest, disable UWSGI/NGINX/CELERY_SHORT/CELERY_LONG/MIGRATIONS, depends on mediacms-redis
16. mediacms-db - image: postgres:17.2-alpine, env vars: POSTGRES_USER=mediacms, POSTGRES_PASSWORD=mediacms, POSTGRES_DB=mediacms, healthcheck using pg_isready, named volume mediacms_db_data
17. mediacms-redis - image: redis:alpine, healthcheck using redis-cli ping
18. prometheus - image: prom/prometheus:v3.11.2, mounts ./monitoring/prometheus.yml to /etc/prometheus/prometheus.yml:ro, no host ports
19. grafana - image: grafana/grafana:latest, env vars: GF_SERVER_ROOT_URL=http://localhost/grafana/, GF_SERVER_SERVE_FROM_SUB_PATH=true, GF_SECURITY_ADMIN_PASSWORD=admin, mounts grafana provisioning directories, no host ports
20. cadvisor - image: gcr.io/cadvisor/cadvisor:latest, mounts /var/run:/var/run:ro, /sys:/sys:ro, /var/lib/docker:/var/lib/docker:ro, privileged: true
All MediaCMS services mount a shared volume mediacms_data to /home/mediacms.io/mediacms/.
All Jitsi services share the meet.jitsi network alias pattern and use env_file: ./jitsi/.env.
Add healthchecks on every database service.
Use named volumes for all database data.
What does this prompt produce?
Cursor Composer generates a single docker-compose.yml that wires together all 15+ containers. The key design decisions are:
- One shared edtech-net bridge network so every service can reach every other service by its Docker Compose service name.
- Health checks on every database so dependent services wait until the database is ready before starting.
- Only NGINX and JVB expose host ports. All other services are internal-only, accessed through the NGINX reverse proxy.
- Named volumes for database persistence so your data survives container restarts.
- Review the generated file in Cursor. Confirm it contains all the services listed in the prompt.
- Save docker-compose.yml.
Pro tip: count your services
Open the generated docker-compose.yml and count the top-level keys under services:. You should see at least 20 service definitions (the "15 containers" count in the outline refers to the core application services, but supporting databases, Redis, and workers add more).
Why so many containers?
This mirrors how real national platforms like DIKSHA work. Each concern (identity, content, video, monitoring) runs as an isolated microservice with its own database. If the video transcoding worker crashes, the LMS keeps running. That isolation is the whole point of a microservice architecture.
Cursor generated an incomplete file?
If Cursor truncated the output, send a follow-up prompt: "Continue generating the docker-compose.yml from the last service you completed." Cursor Composer in agent mode can resume where it left off.
If services are missing entirely, re-send the prompt with only the missing services listed.
Help me fix my docker-compose.yml. Some services are missing.
Generate the NGINX reverse proxy configuration
NGINX is the single entry point for the entire platform. Every request from a student's browser hits NGINX first, and NGINX routes it to the correct backend service based on the URL path.
- Send this prompt to Cursor Composer to generate nginx/nginx.conf:
Generate nginx/nginx.conf for the edtech platform reverse proxy.
Upstream blocks:
- moodle: points to moodle-nginx:80
- keycloak: points to keycloak:8080
- jitsi: points to jitsi-web:80
- mediacms: points to mediacms-web:80
- grafana: points to grafana:3000
- prometheus: points to prometheus:9090
Location blocks:
- /lms -> proxy_pass to moodle upstream
- /auth -> proxy_pass to keycloak upstream
- /meet -> proxy_pass to jitsi upstream
- /media -> proxy_pass to mediacms upstream
- /grafana/ -> proxy_pass to grafana upstream, with rewrite to strip /grafana prefix
- /prometheus -> proxy_pass to prometheus upstream
For ALL location blocks, include these proxy headers:
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
For Jitsi, Keycloak, and Grafana locations, add WebSocket upgrade support:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
Add a stub_status endpoint at /nginx_status that only allows access from the Docker network (allow 172.16.0.0/12; deny all).
Set worker_processes auto and worker_connections 1024.
Set client_max_body_size to 512M (for video uploads to MediaCMS).
What does this configuration do?
- Each upstream block maps a friendly name to a Docker service hostname and port. NGINX resolves these using Docker's internal DNS.
- The proxy_set_header directives pass the original client IP and protocol to backend services. Without these, Keycloak cannot generate correct redirect URLs.
- WebSocket upgrade headers are required for Jitsi Meet video streams, Keycloak admin console live updates, and Grafana Live dashboards.
- The stub_status endpoint exposes basic NGINX metrics (active connections, request counts) that Prometheus will scrape later.
- Review the generated nginx.conf in Cursor.
- Save nginx/nginx.conf.
Now launch the full stack. This single command pulls all images and starts every container.
- Open your terminal in the edtech-platform folder.
- Launch all services in detached mode by running this command:
docker compose up -d
What should I see?
Docker pulls all images on the first run. This takes 5-15 minutes depending on your internet speed. You will see lines like Pulling moodle-db ... done for each service.
The Moodle PHP-FPM container downloads Moodle 5.1.2 at first startup. This adds another 2-5 minutes before Moodle is ready. The MediaCMS migrations container also runs database setup on first boot.
Seeing port conflicts?
The stack uses port 80 (NGINX) and port 10000/udp (JVB). If another application is already using port 80, you will see an error like Bind for 0.0.0.0:80 failed: port is already allocated.
Stop the conflicting service before retrying. On Windows, IIS or Apache may occupy port 80. On macOS, check for any running web servers.
- Wait 3-5 minutes for images to pull and containers to initialize.
- Check container status by running this command:
docker compose ps
You should see all containers listed with a status of running or healthy. Database containers should show healthy once their health checks pass.
Some containers show 'exited' or 'restarting'?
Check the logs for the failing container by running:
docker compose logs <service-name>
Replace <service-name> with the name of the failing container (e.g., moodle-php, keycloak, mediacms-migrations).
- If moodle-php is restarting, the database health check may not have passed yet. Wait another minute and check again.
- If mediacms-migrations exited with code 0, that is expected. It runs once and exits after completing database setup.
- If keycloak shows a database connection error, confirm keycloak-db is healthy first.
Help me debug my Docker Compose containers that are failing to start.
Validate every service through NGINX
Every service should now be reachable through the NGINX reverse proxy on localhost. Open each URL in your browser to confirm.
- Open http://localhost/lms in your browser. You should see the Moodle installation page or login screen.
- Open http://localhost/auth. You should see the Keycloak welcome page or admin console login.
- Open http://localhost/meet. You should see the Jitsi Meet landing page where you can start a meeting.
- Open http://localhost/media. You should see the MediaCMS homepage.
- Open http://localhost/grafana. You should see the Grafana login page. Log in with username admin and password admin.
Why does Moodle take longer to load?
The netresearch/moodle-docker stack downloads Moodle 5.1.2 at first startup and generates config.php from environment variables. This can take 2-5 minutes on the first boot. If you see a 502 error, wait a minute and refresh.
- Take a screenshot showing at least three of these services loaded in your browser.
Seeing 502 Bad Gateway on one of the URLs?
A 502 means NGINX cannot reach the backend service. Check that the specific service is running:
docker compose ps <service-name>
- If the service is not running, check its logs with docker compose logs <service-name>.
- If the service is running but NGINX still returns 502, verify the upstream hostname in nginx.conf matches the service name in docker-compose.yml. Docker DNS resolves service names, so they must match exactly.
- Restart NGINX after any config changes by running docker compose restart nginx.
Help me fix a 502 Bad Gateway error from NGINX.
All five front-ends are now reachable through a single NGINX entry point. Next up, you will configure Keycloak so that one login grants access across the entire platform.
Configure Keycloak Identity Federation
Your 15-container stack is running and every service is reachable through NGINX. The next challenge is identity.
A national education platform serves millions of users across multiple applications. Without centralized identity, each application forces its own login. Keycloak solves this by acting as a single identity provider. You configure one realm, create users and roles, and register each application as an OpenID Connect client. Every application trusts Keycloak, so one login works everywhere.
In this step, get ready to:
- Create a Keycloak realm with teacher and student roles.
- Register Moodle as a confidential OpenID Connect client.
- Verify the OIDC discovery endpoint serves valid metadata.
Create the edtech realm, roles, and users
Keycloak organizes everything into realms. A realm is an isolated group of users, roles, and applications. The default master realm is reserved for Keycloak administration. You create a separate realm for your platform's users.
- Open the Keycloak admin console at http://localhost/auth in your browser.
- Log in with the bootstrap admin credentials you set in your docker-compose.yml (the KC_BOOTSTRAP_ADMIN_USERNAME and KC_BOOTSTRAP_ADMIN_PASSWORD values).
What is a realm?
A realm in Keycloak is equivalent to a tenant. Each realm has its own users, credentials, roles, and clients. Realms are completely isolated from each other. This is the same pattern DIKSHA uses with Sunbird Identity, where each of India's 36 states can manage its own identity pool.
- Click Manage realms in the left column.
- Click Create realm.
- Enter edtech in the Realm name field.
- Click Create.
The admin console switches to the edtech realm automatically. You can confirm this by checking the realm name displayed in the left sidebar.
Now create two realm roles that represent the user types on your platform.
- Click Realm roles in the left menu.
- Click Create role.
- Enter teacher as the role name.
- Click Save.
- Click Realm roles again to return to the roles list.
- Click Create role again.
- Enter student as the role name.
- Click Save.
Next, create two test users and assign them their roles.
- Click Users in the left menu.
- Click Create new user.
- Enter teacher01 as the username.
- Enter Teacher as the first name and One as the last name.
- Enter teacher01@edtech.local as the email.
- Click Create.
Set a password for this user.
- Click the Credentials tab at the top of the user page.
- Click Set password.
- Enter a password you will remember (for example, teacher123).
- Toggle Temporary to Off so the user is not forced to change the password on first login.
- Click Save.
- Confirm by clicking Save password in the confirmation dialog.
Assign the teacher role to this user.
- Click the Role mapping tab.
- Click Assign role.
- Select the teacher role from the list.
- Click Assign.
Repeat the process to create the student user.
- Click Users in the left menu.
- Click Create new user.
- Enter student01 as the username.
- Enter Student as the first name and One as the last name.
- Enter student01@edtech.local as the email.
- Click Create.
- Click the Credentials tab, click Set password, enter a password (for example, student123), toggle Temporary to Off, and click Save.
- Confirm by clicking Save password.
- Click the Role mapping tab, click Assign role, select student, and click Assign.
You should now see both teacher01 and student01 listed under Users in the edtech realm.
Don't see the realm roles when assigning?
If the role list appears empty when you click Assign role, make sure you are filtering by realm roles (not client roles). The filter dropdown at the top of the role list defaults to showing all roles. Select Filter by realm roles to see teacher and student.
Still stuck? Help me find realm roles when assigning a role to a user in Keycloak
Register Moodle as an OpenID Connect client
For Moodle to redirect users to Keycloak for login, it needs to be registered as a client. A confidential client means Moodle authenticates itself with a secret when exchanging tokens with Keycloak.
- Confirm you are in the edtech realm (check the realm name in the left sidebar).
- Click Clients in the left menu.
- Click Create client.
The client creation wizard has three steps. Fill in the first step:
- Set Client type to OpenID Connect.
- Enter moodle-client as the Client ID.
- Click Next.
On the capability configuration step:
- Toggle Client authentication to On. This makes the client confidential, meaning it uses a secret to authenticate with Keycloak.
- Confirm Standard flow is enabled (it should be on by default).
- Confirm Direct access grants is enabled.
- Click Next.
Why enable Client authentication?
Turning Client authentication on makes this a confidential client. Moodle stores a client secret and uses it when exchanging authorization codes for tokens. This prevents unauthorized applications from impersonating Moodle in the token exchange.
On the login settings step:
- Set Valid redirect URIs to http://localhost/lms/admin/oauth2callback.php.
- Set Base URL to http://localhost/lms.
- Set Web origins to http://localhost.
- Click Save.
Why does the redirect URI matter?
The redirect URI is the exact URL Keycloak sends the user back to after login. Moodle's OAuth2 callback lives at /admin/oauth2callback.php. If this URI does not match exactly, Keycloak rejects the login attempt with an "Invalid redirect" error.
Now copy the client secret that Moodle will use to authenticate with Keycloak.
- Click the Credentials tab at the top of the moodle-client page.
- Copy the value shown in the Client secret field. Save it somewhere you can paste it later (a text file or your clipboard).
Pro tip: keep the secret handy
You will paste this client secret into Moodle's OAuth2 configuration in the next step. If you lose it, you can always return to Clients in the Keycloak admin console, click moodle-client, and find it under the Credentials tab.
Don't see a Credentials tab?
The Credentials tab only appears for confidential clients. If it is missing, go back to the Settings tab and confirm Client authentication is toggled On. Save the change, then refresh the page.
Still having trouble? Help me find the client secret for my Keycloak OIDC client
Verify the OIDC discovery endpoint
Before connecting Moodle, confirm that Keycloak is serving the OpenID Connect discovery document. This is the JSON file that Moodle reads to automatically discover all the endpoints it needs for login, token exchange, and user info.
- Open a new browser tab.
- Navigate to the following URL:
http://localhost/auth/realms/edtech/.well-known/openid-configuration
You should see a JSON document in your browser. Confirm it contains these three key fields:
- authorization_endpoint pointing to the Keycloak authorize URL.
- token_endpoint pointing to the Keycloak token URL.
- userinfo_endpoint pointing to the Keycloak user info URL.
What is the OIDC discovery document?
The .well-known/openid-configuration endpoint is a standard defined by OpenID Connect. It publishes all the URLs and capabilities of the identity provider in one JSON file. When you give Moodle the base URL of your Keycloak realm, Moodle fetches this document and automatically configures itself with the correct authorization, token, and user info endpoints.
Getting a 502 or blank page?
If the discovery URL returns a 502 error, Keycloak may still be starting up. Run the following command to check its status:
docker compose logs keycloak --tail 20
Look for a line containing Keycloak started or Listening on. If Keycloak is not ready yet, wait 30 seconds and try the URL again.
If the URL returns a 404, verify that the realm name in the URL matches edtech exactly (lowercase). Also confirm your NGINX config proxies /auth to the Keycloak container.
Still stuck? Help me debug why the Keycloak OIDC discovery endpoint is not responding
Your identity provider is ready. You have a Keycloak realm with two roles, two users, a registered OIDC client for Moodle, and a working discovery endpoint. Next up, you will wire Moodle to this Keycloak realm so that a single login grants access to the entire platform.
Wire Moodle to Keycloak SSO and Install mod_jitsi
In the previous step, you set up Keycloak with an edtech realm, two users, and an OpenID Connect client ready for Moodle.
Now you will connect Moodle to Keycloak so that a single login works across the entire platform. You will also install the mod_jitsi plugin, which embeds live Jitsi video classrooms directly inside Moodle courses with attendance tracking and activity completion.
In this step, get ready to:
- Configure Moodle's built-in OAuth 2 service to connect to Keycloak for single sign-on.
- Install and configure the mod_jitsi plugin so Moodle can host embedded Jitsi video classrooms.
- Test the full SSO flow by logging in as a student through Keycloak and landing in Moodle.
Configure Moodle's OAuth 2 service for Keycloak SSO
Moodle has a built-in OAuth 2 services feature that supports OpenID Connect auto-discovery. When you provide Keycloak's base URL, Moodle automatically discovers all the OIDC endpoints it needs (authorization, token, userinfo) from the .well-known/openid-configuration document.
Why use Moodle's built-in OAuth 2 instead of a separate plugin?
Moodle core has supported OAuth 2 services since version 3.3. This built-in approach auto-discovers OIDC endpoints from the Keycloak discovery document, so you only need to provide the base URL. The alternative auth_oidc plugin is designed primarily for Microsoft 365 and Azure AD. The built-in method is simpler and uses Moodle core functionality.
Moodle's initial setup wizard runs on first access. You need to complete it before configuring OAuth 2.
- Open http://localhost/lms in your browser.
- Accept the license agreement.
- Configure the admin account (set a username, password, email, first name, and last name).
- Complete the site registration page (you can skip optional fields).
Why does Moodle take a while to load?
The netresearch/moodle-docker stack downloads Moodle on first startup. This can take 2-5 minutes depending on your network speed. The initial setup wizard also runs database migrations. Be patient and let it finish.
Once the setup wizard is complete and you are logged in as admin, create the Keycloak OAuth 2 service.
- Click Site administration in the left sidebar.
- Navigate to Server, then click OAuth 2 services.
- Scroll to the bottom and click Create new custom service.
Fill in the service form with the following values:
- Set Name to Keycloak EdTech.
- Set Client ID to moodle-client.
- Paste the client secret you copied from Keycloak's Credentials tab into the Client Secret field.
- Set Service Base URL to http://keycloak:8080/realms/edtech.
- Set Scopes included in a login request to openid profile email.
- Check the Show on login page checkbox.
- Click Save changes.
Why use the Docker hostname instead of localhost?
The Service Base URL uses http://keycloak:8080 because Moodle communicates with Keycloak server-to-server inside the Docker network. Using localhost would point Moodle at itself. Docker's internal DNS resolves keycloak to the correct container on the edtech-net bridge network.
Moodle auto-discovers all OIDC endpoints from Keycloak's discovery document. You should see the service listed with green checkmarks for the endpoints.
Now enable the OAuth 2 authentication plugin so the Keycloak login button appears on the Moodle login page.
- Navigate to Site administration, then Plugins, then Authentication, then Manage authentication.
- Find OAuth 2 in the list of authentication plugins.
- Click the eye icon next to OAuth 2 to enable it (the eye should change from closed to open).
The Moodle login page now shows a Keycloak login button. You will test it at the end of this step.
Don't see the OAuth 2 service listed?
Make sure the Service Base URL is exactly http://keycloak:8080/realms/edtech with no trailing slash. Verify the Keycloak container is running with docker compose ps. The Client ID must match moodle-client exactly as you set it in Keycloak.
Help me troubleshoot my Moodle OAuth 2 Keycloak connection
Install the mod_jitsi plugin in Moodle
The mod_jitsi plugin (maintained by UDIMA, GPL-3.0, 3,491+ sites) embeds Jitsi Meet conferences directly inside Moodle course activities. Students get attendance tracking, activity completion, and avatar integration. A bare URL link to Jitsi provides none of these features.
You will use Cursor Composer to generate a script that downloads the plugin, copies it into the running Moodle container, and triggers the installation.
- Open Cursor Composer in agent mode.
- Enter the following prompt to generate the installation script:
Generate a PowerShell script that:
1. Downloads the latest mod_jitsi release zip from https://github.com/udima-university/moodle-mod_jitsi/archive/refs/heads/master.zip
2. Extracts it to a temp directory
3. Renames the extracted folder from moodle-mod_jitsi-master to jitsi
4. Uses docker compose cp to copy the jitsi folder into the Moodle PHP-FPM container at /var/www/html/mod/jitsi
5. Sets correct ownership inside the container with docker compose exec
The Moodle PHP-FPM service name in docker-compose.yml is moodle-php-fpm.
The web root inside the container is /var/www/html.
What does this script do?
The script downloads the mod_jitsi plugin source code from GitHub, extracts it, and copies it into the Moodle container's /var/www/html/mod/jitsi directory using docker compose cp. This is how you install Moodle plugins in a Docker environment where you cannot use the web-based installer.
- Review the generated script and run it in your terminal.
- After the script completes, open http://localhost/lms in your browser.
- Navigate to Site administration, then Notifications.
Moodle detects the new plugin and shows a database upgrade notification. Click Upgrade Moodle database now to install the plugin.
- Click Upgrade Moodle database now.
- Wait for the upgrade to complete, then click Continue.
Now configure mod_jitsi to point at your self-hosted Jitsi server running in Docker.
- Navigate to Site administration, then Plugins, then Activity modules, then Jitsi.
- Set the Domain field (jitsi_domain) to your Docker Jitsi service hostname. For the lab, use localhost:8443 (the HTTPS port exposed by the Jitsi web container).
- Click Save changes.
What does mod_jitsi give you over a plain URL link?
- Attendance tracking records which students joined and for how long.
- Activity completion lets teachers require a minimum attendance time before marking the activity as done.
- Moodle profile pictures are used as conference avatars.
- Guest URLs let non-Moodle users join sessions.
Plugin not detected by Moodle?
Make sure the plugin folder is named exactly jitsi (not moodle-mod_jitsi-master) inside /var/www/html/mod/. The folder must contain version.php at its root. Verify by running:
docker compose exec moodle-php-fpm ls /var/www/html/mod/jitsi/version.php
If the file is not found, re-run the copy step and check the folder structure.
Help me debug the mod_jitsi plugin installation in my Docker Moodle container
Test SSO end-to-end
Everything is wired up. Time to prove that a student can log in once through Keycloak and land inside Moodle with the correct identity.
- Open a private/incognito browser window (this avoids using your existing admin session).
- Navigate to http://localhost/lms.
- On the Moodle login page, click the Keycloak EdTech login button.
You are redirected to the Keycloak login page for the edtech realm.
- Enter the username student01 and the password you set in the previous step.
- Click Sign In.
Keycloak authenticates the user and redirects back to Moodle. You should land on the Moodle dashboard, logged in as student01 with the correct first and last name displayed in the top-right corner.
What is happening behind the scenes?
This is the OIDC authorization code flow in action. Moodle redirects the browser to Keycloak's authorization endpoint. Keycloak authenticates the user and returns an authorization code. Moodle exchanges that code for an access token, then fetches the user's profile from Keycloak's userinfo endpoint. If account creation is enabled (it is by default), Moodle creates a new account and logs the user in.
- Verify the linked identity by clicking the user menu in the top-right corner.
- Click Preferences.
- Click Linked logins.
You should see the Keycloak EdTech service listed with the student01 identity connected. This confirms the SSO link is working.
Redirect fails or shows an error?
- Check that the Valid Redirect URI in Keycloak is exactly http://localhost/lms/admin/oauth2callback.php. A mismatch causes a redirect error.
- Verify the Web Origins field in Keycloak is set to http://localhost.
- Make sure the OAuth 2 authentication plugin is enabled (open eye icon) under Manage authentication.
Help me fix the Keycloak to Moodle SSO redirect error
Your platform now has single sign-on and embedded live classrooms. Next up, you will create a course with a Jitsi live classroom activity and add video-on-demand content from MediaCMS.
Create a Course with Live Classroom and VOD Content
Your platform now has a working SSO flow. A student can log in once through Keycloak and land inside Moodle as an authenticated user. But there is nothing for them to learn yet.
In this step, you will create a course, embed a live video classroom inside it, and link a recorded lecture from your video-on-demand library. By the end, a student opening your platform sees a real course with both live and on-demand content.
In this step, get ready to:
- Create a Moodle course and add an embedded Jitsi live classroom activity.
- Upload a video to MediaCMS and verify HLS adaptive streaming.
- Generate four Mermaid architecture diagrams documenting your platform.
Create a course and add a Jitsi live classroom
The mod_jitsi plugin you installed in the previous step turns Jitsi Meet rooms into native Moodle activities. Students get attendance tracking, activity completion, and avatar integration without ever leaving the course page.
Before teacher01 can create courses, they need the Course Creator role in Moodle. Keycloak roles do not automatically map to Moodle roles, so you will assign this manually as the Moodle admin first.
- Open http://localhost/lms and log in with your Moodle admin account (the one you created during initial setup in Step 4).
- Navigate to Site Administration.
- Select Users, then Assign system roles.
- Click Course creator.
- Search for teacher01 in the search box, select them, and click Add.
- Log out of the admin account.
Why assign the role manually?
Keycloak manages authentication (proving who you are). Moodle manages authorization (what you can do). The teacher role in Keycloak tells Moodle the user is a teacher, but Moodle's Course Creator role is a separate permission. In production, you would automate this mapping with a custom Moodle plugin or Keycloak mapper.
Now log in as teacher01 and create the course.
- Open a private/incognito browser window.
- Go to http://localhost/lms.
- Click the Keycloak login option and authenticate as teacher01 using the password you set in Step 3.
- Navigate to Site Administration, then Courses, then Manage courses and categories.
- Click New course.
- Enter Introduction to Computer Science in the Course full name field.
- Enter CS101 in the Course short name field.
- Click Save and display.
You should see the empty course homepage for "Introduction to Computer Science".
Now add the Jitsi live classroom activity.
- Toggle the Edit mode switch in the top right corner of the course page.
- Click Add an activity or resource in the first course section.
- Select Jitsi from the activity chooser.
- Enter Live Lecture: Week 1 in the Session name field.
- Click Save and return to course.
You should see "Live Lecture: Week 1" listed as an activity on the course page.
- Click Live Lecture: Week 1 to open it.
- Click the join button to enter the Jitsi room.
The Jitsi video conference should load directly inside the Moodle page frame. You should see the video/audio interface with camera and microphone controls.
Jitsi room not loading?
If you see a blank frame or connection error, check that the Jitsi domain is correctly configured in mod_jitsi settings. Navigate to Site Administration > Plugins > Activity Modules > Jitsi and verify the domain matches your Docker Jitsi service hostname.
Also confirm all four Jitsi containers are running by checking docker compose ps for jitsi-web, jitsi-prosody, jitsi-jicofo, and jitsi-jvb.
Still stuck? Help me debug why my Jitsi room isn't loading inside the Moodle course activity.
Upload a lecture video to MediaCMS and link it in the course
Live classrooms cover synchronous learning. For asynchronous content, your platform needs video-on-demand. MediaCMS automatically transcodes uploaded videos into multiple HLS resolutions using its Celery workers, so students on slow connections get a lower-quality stream instead of buffering.
- Open http://localhost/media in your browser.
- Log in with the MediaCMS admin credentials from your docker-compose.yml (username: admin, with the password you set via the ADMIN_PASSWORD environment variable).
- Click the Upload Media button in the top right.
- Select any short MP4 file from your computer (30 seconds to 2 minutes is ideal for testing).
- Enter a title like CS101 - Introduction to Algorithms and click Save.
What happens after you upload?
MediaCMS sends the video to its Celery worker containers for transcoding. FFmpeg runs inside the worker and creates multiple resolution profiles (e.g. 240p, 360p, 720p). Each profile becomes an HLS stream with .m3u8 playlist files and .ts segment files. A 30-second video typically takes 2-3 minutes to transcode all profiles.
- Monitor the transcoding progress by running this command:
docker compose logs -f mediacms-celery-worker
- Wait until you see log lines indicating the encoding tasks have completed. Press Ctrl+C to stop following the logs.
- Refresh the video page in MediaCMS.
- Click the gear icon on the video player to open the quality selector.
You should see multiple resolution options (such as 240p, 360p, 720p) in the quality menu. This confirms HLS adaptive streaming is working.
Only seeing "Auto" in the quality selector?
Transcoding may still be in progress. Check the Celery worker logs again for active tasks. A short video can take 2-3 minutes. A longer video takes proportionally more time.
If the worker container shows errors, verify it has at least 1 GB of RAM available. You can check container resource usage with docker stats.
Need more help? Help me troubleshoot MediaCMS video transcoding.
Now link this video back into your Moodle course so students find everything in one place.
- Copy the video's public URL from your browser's address bar on the MediaCMS video page.
- Switch back to your Moodle course page (still logged in as teacher01).
- Make sure Edit mode is still toggled on.
- Click Add an activity or resource.
- Select URL from the resource list.
- Enter Recorded Lecture: Week 1 in the Name field.
- Paste the MediaCMS video URL into the External URL field.
- Click Save and return to course.
Your course page should now show two activities: "Live Lecture: Week 1" (the Jitsi classroom) and "Recorded Lecture: Week 1" (the MediaCMS video link).
Why not embed MediaCMS directly like Jitsi?
MediaCMS does not have a native Moodle plugin like mod_jitsi. A URL resource is the simplest integration. In production, you could build an LTI provider for MediaCMS or use Moodle's iframe embed to display the video player inline.
Course page not showing both activities?
Make sure Edit mode is on. Activities only appear in the activity chooser when editing is enabled. If you saved but do not see the activity, refresh the course page.
Still not working? Help me figure out why my Moodle course activities are not appearing.
Generate four Mermaid architecture diagrams
Your platform is now functionally complete with identity, live video, and on-demand content all connected. Before moving to monitoring, you will document the architecture with four diagrams that map every component and data flow.
- Open Cursor Composer in agent mode with parallel prompts enabled.
- Send all four prompts simultaneously to generate the diagrams. Each diagram should be saved as a separate Markdown file in your docs/ directory.
Use these four prompts in Cursor Composer:
Prompt 1: System topology diagram.
Generate a Mermaid diagram showing the full system topology of my edtech platform.
Include all 15 containers: NGINX (entry point), Moodle (nginx + PHP-FPM + MariaDB + Valkey),
Keycloak + PostgreSQL, Jitsi (web + prosody + jicofo + JVB), MediaCMS (web + celery_worker +
celery_beat + migrations + PostgreSQL + Redis), and the monitoring stack (Prometheus, Grafana,
cAdvisor). Show the edtech-net Docker bridge network. Show NGINX as the single entry point
routing to each service. Show database connections. Show the Prometheus/Grafana/cAdvisor
monitoring loop. Save as docs/diagram-topology.md.
Prompt 2: SSO dataflow diagram.
Generate a Mermaid sequence diagram showing the OpenID Connect authorization code flow
between a student's browser, NGINX, Moodle, and Keycloak. Show: (1) student visits
http://localhost/lms, (2) clicks Keycloak login, (3) NGINX proxies to Keycloak,
(4) student authenticates, (5) Keycloak redirects back with auth code,
(6) Moodle exchanges code for tokens via back-channel to Keycloak,
(7) Moodle creates session and redirects student to course page.
Save as docs/diagram-sso.md.
Prompt 3: Video delivery workflow diagram.
Generate a Mermaid flowchart showing the video delivery pipeline. Show: (1) teacher uploads
MP4 to MediaCMS, (2) MediaCMS sends to Celery worker, (3) FFmpeg transcodes into multiple
HLS resolutions, (4) .m3u8 playlists and .ts segments stored, (5) student requests video
via NGINX, (6) NGINX checks vod_cache, (7) cache HIT serves directly, cache MISS fetches
from MediaCMS, (8) adaptive bitrate player selects quality based on bandwidth.
Save as docs/diagram-video.md.
Prompt 4: Observability pipeline diagram.
Generate a Mermaid flowchart showing the observability pipeline. Show: (1) cAdvisor mounts
Docker socket and reads container metrics (CPU, memory, network), (2) NGINX exposes
stub_status endpoint, (3) Prometheus scrapes cAdvisor every 15 seconds, (4) Prometheus
scrapes NGINX stub_status, (5) Prometheus stores time-series data, (6) Grafana queries
Prometheus via PromQL, (7) Grafana renders real-time dashboards at /grafana.
Save as docs/diagram-observability.md.
What are these diagrams for?
These four diagrams form the visual backbone of your architecture documentation in Step 8. The topology diagram shows how everything connects. The SSO diagram proves you understand the authentication flow. The video diagram maps the content delivery pipeline. The observability diagram documents your monitoring stack.
- Verify all four files exist by running:
ls docs/diagram-*.md
You should see diagram-topology.md, diagram-sso.md, diagram-video.md, and diagram-observability.md in the output.
Pro tip: preview your diagrams
You can preview Mermaid diagrams directly in Cursor by opening any of the .md files and using the Markdown preview pane. You can also paste the Mermaid code into the Mermaid Live Editor for an interactive view.
Cursor not generating valid Mermaid syntax?
If the generated diagrams have syntax errors, paste them into the Mermaid Live Editor to see exactly which line is failing. Common issues include missing arrow syntax or unescaped special characters in labels.
Ask Cursor to fix it: Help me fix the Mermaid syntax errors in my diagram.
Your course now has both live and recorded content. A student logging in through Keycloak can join a live classroom or watch a recorded lecture, all from a single course page. Next up, you will build the monitoring stack that watches every container in real time.
Configure the Monitoring Stack
Your platform has SSO, live classrooms, and video-on-demand all working through a single entry point. But running 15 containers without visibility into their health is like flying blind.
In this step, you will wire up the observability layer. Prometheus scrapes metrics from every container through cAdvisor, and Grafana turns those metrics into real-time dashboards you can watch during a load test.
In this step, get ready to:
- Generate the Prometheus and Grafana provisioning config files.
- Verify the Prometheus data source in Grafana and import a cAdvisor dashboard.
- Stress-test the stack and watch the dashboard respond in real time.
Generate the Prometheus and Grafana provisioning configs
Prometheus needs a configuration file that tells it which services to scrape and how often. Grafana needs a provisioning file that auto-connects it to Prometheus on startup.
- Open Cursor Composer in agent mode.
- Use the following prompt to generate both config files at once:
Generate two YAML config files for my monitoring stack:
1. monitoring/prometheus.yml - A Prometheus config with:
- global scrape_interval of 15s
- global evaluation_interval of 15s
- Three scrape jobs:
a) job_name: 'cadvisor' targeting cadvisor:8080 with 15s scrape_interval
b) job_name: 'nginx' targeting nginx-exporter:9113 with 15s scrape_interval
c) job_name: 'prometheus' targeting localhost:9090 for self-monitoring
- Add inline comments explaining each job
2. monitoring/grafana/provisioning/datasources/prometheus.yml - A Grafana
datasource provisioning file with:
- apiVersion: 1
- A single datasource named 'Prometheus'
- type: prometheus
- access: proxy
- url: http://prometheus:9090
- isDefault: true
- editable: false
What does this prompt produce?
Two files that form the backbone of your observability pipeline:
- The Prometheus config defines three scrape jobs. The cadvisor job collects CPU, memory, and network metrics from every container. The nginx job collects request rate and connection count from the NGINX stub_status exporter. The prometheus job lets Prometheus monitor itself.
- The Grafana datasource file tells Grafana to auto-configure Prometheus as its default data source at startup. No manual setup needed.
Why an NGINX exporter instead of scraping stub_status directly?
NGINX's stub_status module outputs plain text that Prometheus cannot parse natively. The nginx-prometheus-exporter sidecar reads that plain text and converts it into Prometheus-compatible metrics. Your docker-compose.yml from Step 2 already includes this exporter container.
Cursor should generate two files. Verify the Prometheus config looks similar to this:
# Prometheus configuration for the edtech monitoring stack
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
# Collect container metrics from cAdvisor
- job_name: 'cadvisor'
scrape_interval: 15s
static_configs:
- targets: ['cadvisor:8080']
# Collect NGINX request and connection metrics
- job_name: 'nginx'
scrape_interval: 15s
static_configs:
- targets: ['nginx-exporter:9113']
# Prometheus self-monitoring
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
What does each scrape job do?
- cadvisor:8080 exposes per-container CPU, memory, network, and filesystem metrics. Every 15 seconds, Prometheus pulls a fresh snapshot from cAdvisor.
- nginx-exporter:9113 translates NGINX's stub_status output into Prometheus metrics like nginx_http_requests_total and nginx_connections_active.
- localhost:9090 is Prometheus scraping its own metrics endpoint. This lets you monitor the monitor.
Verify the Grafana datasource provisioning file looks similar to this:
# Auto-provision Prometheus as the default Grafana data source
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
What does this file do?
When Grafana starts, it reads YAML files from its provisioning/datasources/ directory. This file tells Grafana to create a data source named "Prometheus" pointing at http://prometheus:9090 (the Docker service name). Setting isDefault: true means every new dashboard panel defaults to querying Prometheus.
- Save both files.
- Restart the monitoring containers to pick up the new configs by running:
docker compose restart prometheus grafana
- Wait about 30 seconds for both services to restart and load their configs.
Prometheus not starting?
- Check for YAML indentation errors. Prometheus is strict about whitespace. Run docker compose logs prometheus to see the exact error message.
- Make sure the file is saved at monitoring/prometheus.yml (not inside a subfolder). The volume mount in your docker-compose.yml expects this exact path.
Help me debug why Prometheus won't start with my config.
Verify the data source and import the cAdvisor dashboard
Grafana should now have Prometheus pre-configured as its data source. You will verify this connection, then import a community dashboard that visualizes all the container metrics cAdvisor collects.
- Open Grafana at http://localhost/grafana in your browser.
- Log in with username admin and password admin.
- Grafana prompts you to change the default password on first login. Set a new password or click Skip.
Now verify the Prometheus data source is working.
- Click the hamburger menu (three horizontal lines) in the top-left corner.
- Select Connections, then Data sources.
- Click on the Prometheus data source in the list.
- Scroll to the bottom and click Test.
You should see a green banner that says "Data source is working".
Data source test failing?
- Make sure the Prometheus container is running. Run docker compose ps prometheus and confirm it shows "running" or "healthy".
- Verify the URL in the data source settings is http://prometheus:9090 (the Docker service name, not localhost). Grafana connects to Prometheus over the Docker network.
Help me fix my Grafana-to-Prometheus connection.
Now import the cAdvisor dashboard to visualize container metrics.
- Click the hamburger menu again.
- Select Dashboards.
- Click New in the top-right corner, then select Import.
- In the Grafana.com dashboard URL or ID field, enter:
14282
- Click Load.
- On the import screen, select Prometheus from the data source dropdown.
- Click Import.
The cAdvisor Exporter dashboard loads immediately. You should see panels for CPU Usage by container, Memory Usage, Memory Cached, Received Network Traffic, and Sent Network Traffic.
What is dashboard 14282?
This is the "Cadvisor exporter" dashboard from the Grafana community library. It queries cAdvisor metrics that Prometheus scrapes from your containers. Each panel uses PromQL queries like rate(container_cpu_usage_seconds_total{name!=""}[5m]) to calculate real-time CPU usage per container.
Stress-test the stack and watch the dashboard respond
A monitoring dashboard is only useful if it reflects real changes. You will generate load on the stack and watch the Grafana charts react in real time.
- Arrange your screen so you can see the Grafana cAdvisor dashboard in one browser window.
- Open a new terminal window alongside it.
- Generate load on the Moodle front-end by running this command:
# Send 50 rapid requests to Moodle through NGINX to generate a CPU/memory spike
1..50 | ForEach-Object -Parallel { Invoke-WebRequest -Uri 'http://localhost/lms' -UseBasicParsing } -ThrottleLimit 10
What does this command do?
This PowerShell command sends 50 HTTP requests to your Moodle instance in parallel, with up to 10 running at the same time. This simulates a burst of students loading the LMS simultaneously. The NGINX container processes each request, forwards it to Moodle's PHP-FPM workers, and each worker queries MariaDB.
- Switch to your Grafana browser window while the requests are running.
- Watch the CPU Usage panel. The NGINX container and Moodle PHP-FPM container should both show visible spikes.
- Check the Memory Usage panel. Moodle's memory should climb as PHP-FPM spawns workers to handle the parallel requests.
- Look at the Received Network Traffic panel. You should see a burst of inbound traffic on the NGINX container.
Pro tip: set the time range to Last 5 minutes
Click the time picker in the top-right corner of the Grafana dashboard and select Last 5 minutes. This zooms in on recent activity so the load spike is clearly visible instead of being flattened by hours of idle data.
- Take a screenshot of the dashboard during or immediately after the load spike. Save it to docs/grafana-load-test.png for your architecture documentation.
Dashboard panels showing "No data"?
- Verify cAdvisor is running and accessible. Run docker compose ps cadvisor and confirm it shows "running".
- Check that Prometheus can reach cAdvisor. Open http://localhost:9090/targets in your browser. All three scrape targets (cadvisor, nginx, prometheus) should show a green "UP" status.
- If a target shows "DOWN", the container name in your prometheus.yml might not match the service name in your docker-compose.yml. They must be identical.
Help me debug why my Grafana dashboard shows no data.
This is your observability proof. The Grafana dashboard is the control tower for your entire platform. In the next step, you will add NGINX edge caching and run the full validation suite against your stack.
Add NGINX Edge Caching and Run Validation
In the previous step, you built a live monitoring dashboard that watches every container in real time. Now it is time to prove your platform performs under pressure.
A real national education platform like DIKSHA serves millions of page loads per day. Without caching, every request hits the backend directly, wasting CPU and slowing responses. In this step, you will add NGINX edge caching to your reverse proxy and then run five validation tests to benchmark your platform against production-grade thresholds.
In this step, get ready to:
- Add two proxy cache zones to NGINX for static assets and HLS video.
- Run five validation tests covering caching, performance, accessibility, and monitoring health.
- Compile all results into a final validation report with pass/fail benchmarks.
Add proxy cache zones to NGINX
Right now, every request to your platform passes straight through NGINX to the backend services. Adding cache zones lets NGINX store copies of frequently requested files and serve them directly, without touching the backend at all.
- Open Cursor Composer in agent mode.
- Prompt Cursor to update nginx/nginx.conf with two proxy_cache_path zones and matching location blocks by using this prompt:
Update nginx/nginx.conf to add two proxy cache zones:
1. Zone "static_cache" at /var/cache/nginx/static with levels=1:2, keys_zone=static_cache:10m, max_size=100m, inactive=10m. Apply it to a location block matching CSS, JS, and image files (css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2) proxied to the LMS and MediaCMS upstreams. Set proxy_cache_valid 200 10m.
2. Zone "vod_cache" at /var/cache/nginx/vod with levels=1:2, keys_zone=vod_cache:10m, max_size=500m, inactive=60m. Apply it to a location block matching HLS files (.m3u8 and .ts) proxied to the MediaCMS upstream. Set proxy_cache_valid 200 1h.
For BOTH cached location blocks:
- Add: add_header X-Cache-Status $upstream_cache_status;
- Add: proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
Keep all existing upstream blocks, location blocks, and proxy_set_header directives unchanged.
What do these cache zones do?
- static_cache stores CSS, JavaScript, images, and fonts with a 10-minute TTL. These files rarely change and are requested on every page load.
- vod_cache stores HLS video manifests (.m3u8) and transport stream segments (.ts) with a 1-hour TTL. Once a lecture is transcoded, these files do not change.
- The X-Cache-Status header exposes the NGINX variable $upstream_cache_status in every response. It shows MISS on the first request (cache is empty) and HIT on subsequent requests (served from cache).
- Review the generated config to confirm both proxy_cache_path directives appear in the http block and the add_header X-Cache-Status directive appears in both cached location blocks.
- Save nginx/nginx.conf.
- Apply the updated config by running this command:
docker compose restart nginx
- Open your browser's DevTools (press F12 or Cmd+Option+I on macOS, Ctrl+Shift+I on Windows).
- Click the Network tab.
- Navigate to http://localhost/lms.
- Click on any CSS or JS file in the network list and check the Response Headers section.
On the first load, you should see X-Cache-Status: MISS. Refresh the page and check the same file again. It should now show X-Cache-Status: HIT.
Not seeing the X-Cache-Status header?
- Make sure the add_header directive is inside the cached location block, not at the server level. NGINX only adds the header when the request matches that specific location.
- Check that proxy_cache references the correct keys_zone name (static_cache or vod_cache). A mismatch causes NGINX to fail on restart.
- Run docker compose logs nginx to check for config syntax errors.
- Still stuck? Help me debug why my NGINX X-Cache-Status header is not appearing in browser DevTools.
Run all five validation tests
Your platform needs to pass five benchmarks that cover caching performance, response time, accessibility, and monitoring health. You will use Cursor Composer to generate all five validation scripts in one shot.
- Open Cursor Composer in parallel mode.
- Send five simultaneous prompts to generate the validation scripts. Use these five prompts:
Prompt 1 (cache-hit ratio):
Generate a PowerShell script called validation/test-cache-hit.ps1 that:
1. Sends 20 curl requests to static assets on http://localhost/lms (CSS or JS files)
2. Sends the same 20 requests again
3. Parses the X-Cache-Status header from each response
4. Counts HIT vs MISS on the second batch
5. Calculates the hit ratio as a percentage
6. Prints PASS if hit ratio > 80%, FAIL otherwise
7. Outputs the result as a single line: "Cache Hit Ratio: XX% - PASS/FAIL (target: >80%)"
Prompt 2 (VOD TTFB):
Generate a PowerShell script called validation/test-vod-ttfb.ps1 that:
1. Curls an HLS manifest URL (.m3u8) from MediaCMS 5 times
2. Captures the time-to-first-byte (TTFB) for each request using curl's -w "%{time_starttransfer}" option
3. Calculates the average TTFB in milliseconds
4. Prints PASS if average TTFB < 500ms, FAIL otherwise
5. Outputs: "VOD TTFB: XXXms - PASS/FAIL (target: <500ms)"
Prompt 3 (concurrent load):
Generate a PowerShell script called validation/test-concurrent-load.ps1 that:
1. Sends 50 parallel HTTP requests to http://localhost/lms using PowerShell jobs or runspaces
2. Measures the response time for each request
3. Calculates the average response time in seconds
4. Prints PASS if average < 2 seconds, FAIL otherwise
5. Outputs: "Concurrent Load (50 users): X.XXs avg - PASS/FAIL (target: <2s)"
Prompt 4 (Pa11y accessibility):
Generate a PowerShell script called validation/test-accessibility.ps1 that:
1. Checks if pa11y-ci is installed globally, installs it via "npm install -g pa11y-ci" if not
2. Updates pa11y/config.json with these settings:
- defaults.standard: "WCAG2AA"
- defaults.timeout: 30000
- defaults.chromeLaunchConfig.args: ["--no-sandbox"]
- urls: ["http://localhost/lms", "http://localhost/auth", "http://localhost/media"]
3. Runs: pa11y-ci --config pa11y/config.json --json > pa11y/results.json
4. Counts total errors across all URLs from the JSON output
5. Prints PASS if total errors < 50, FAIL otherwise
6. Outputs: "Accessibility (WCAG2AA): XX errors - PASS/FAIL (target: <50)"
Prompt 5 (Grafana health):
Generate a PowerShell script called validation/test-grafana-health.ps1 that:
1. Curls http://localhost/grafana/api/health
2. Parses the JSON response
3. Checks if the "database" field equals "ok"
4. Prints PASS if database is ok, FAIL otherwise
5. Outputs: "Grafana Health: database=ok - PASS/FAIL"
Why five benchmarks?
Each test covers a different quality dimension of your platform. Cache-hit ratio proves your edge caching works. VOD TTFB proves video starts quickly. Concurrent load proves the stack handles multiple users. Pa11y proves accessibility compliance. Grafana health proves your monitoring stack is alive.
- Review the five generated scripts in the validation/ folder.
- Install Pa11y CI globally (if not already installed) by running:
npm install -g pa11y-ci
Pa11y install failing?
- Pa11y CI 4.1.0 requires Node.js 20 or newer. Run node --version to confirm your version.
- On Windows, you may need to run your terminal as Administrator for global npm installs.
- Still stuck? Help me install pa11y-ci on my system.
- Run all five validation scripts one at a time. Start with the cache-hit ratio test by running:
./validation/test-cache-hit.ps1
- Run the VOD TTFB test:
./validation/test-vod-ttfb.ps1
- Run the concurrent load test:
./validation/test-concurrent-load.ps1
- Run the accessibility audit:
./validation/test-accessibility.ps1
- Run the Grafana health check:
./validation/test-grafana-health.ps1
Each script prints a single result line with a PASS or FAIL verdict. Copy all five result lines for the next substep.
Some tests showing FAIL?
- Cache-hit FAIL usually means the static asset URLs in the script do not match your NGINX location regex. Check that the test is requesting files with extensions that match your cached location block (.css, .js, etc.).
- VOD TTFB FAIL can happen if no video has been uploaded to MediaCMS yet, or if transcoding is still in progress. Check docker compose logs mediacms-celery-worker for transcoding status.
- Concurrent load FAIL may indicate Docker Desktop needs more memory. Verify you have at least 14 GB allocated under Settings > Resources.
- Pa11y FAIL with high error counts is expected for default Moodle and Keycloak themes. The target of fewer than 50 errors is intentionally generous.
- Need help diagnosing a specific failure? Help me debug my validation test failures.
Compile the final validation report
Your five test results need a permanent home. The validation report documents what you measured, what the targets were, and whether your platform passed.
- Open Cursor Composer in agent mode.
- Prompt Cursor to populate the validation report by using this prompt:
Populate validation/validation-results.md with the following structure:
# Platform Validation Report
## Test Results
A markdown table with columns: Metric, Measured Value, Target, Result (PASS/FAIL)
Rows:
1. WCAG 2.1 AA Errors | [from Pa11y output] | <50 errors | PASS/FAIL
2. Cache Hit Ratio | [from cache test] | >80% | PASS/FAIL
3. VOD TTFB | [from TTFB test] | <500ms | PASS/FAIL
4. Concurrent User Response Time | [from load test] | <2s | PASS/FAIL
5. Grafana Monitoring Health | [from health check] | database=ok | PASS/FAIL
## Comparison to DIKSHA
Add a brief section comparing our lab metrics to DIKSHA's production scale:
- DIKSHA serves 5.3 billion learning sessions across 36 states
- DIKSHA supports 30+ languages and 300,000+ crowdsourced resources
- Our lab validates the same architectural patterns at local scale
- Production deployment would add Cloudflare CDN, horizontal scaling, and Prometheus federation
## Monitoring Evidence
Describe what the Grafana dashboard showed during the load test:
- Which containers showed CPU spikes
- Memory usage patterns across the stack
- Reference the screenshot saved in docs/
Fill in the measured values from the test outputs I just ran.
What goes in the DIKSHA comparison?
DIKSHA has served 5.3 billion learning sessions across all 36 Indian states. Your lab uses the same architectural patterns (reverse proxy, identity federation, content service, observability) but at local scale. The comparison section makes this mapping explicit and identifies what changes for production (CDN, horizontal scaling, database replication).
- Review the generated validation/validation-results.md to confirm all five metrics have measured values filled in.
- Save validation/validation-results.md.
Your validation report is complete. Open validation/validation-results.md and confirm the table shows all five rows with PASS/FAIL results.
Cursor filled in placeholder values instead of real data?
- Copy the actual output lines from your terminal (the single-line results from each test script) and paste them directly into the Cursor Composer prompt. This gives Cursor the real numbers to use.
- Alternatively, open validation/validation-results.md and manually replace the placeholder values with your actual test results.
- Need help? Help me fill in my validation report with real test data.
Your platform now has measurable proof of performance. Next up, you will write the architecture documentation that maps every component to its DIKSHA/Sunbird equivalent and plans a production deployment for 10 million students.
Architecture Documentation and Industry Evaluation
Your platform is running, monitored, cached, and validated. Now it is time to document what you built and evaluate how it compares to the real thing.
In this step, you will generate a production-grade architecture document that maps every component in your stack to its DIKSHA/Sunbird equivalent, model a real-world deployment for Karnataka, India, and honestly evaluate where your lab stack stands against production requirements.
In this step, get ready to:
- Generate a complete architecture document with component map, Mermaid diagrams, and monitoring stack explanation.
- Add a real-world deployment scenario for Karnataka, India serving 10 million students.
- Evaluate your platform against five production dimensions with evidence-based scoring.
Generate the architecture document with Cursor Composer
Architecture documentation turns a running stack into a reusable blueprint. Without it, someone looking at your 15-container docker-compose.yml has no idea why each service exists or how they relate to each other.
- Open Cursor Composer in agent mode.
- Send the following prompt to generate the architecture document. Use parallel prompts if your Cursor plan supports it:
Generate a complete architecture document at docs/architecture.md for our edtech platform.
Include these sections:
## 1. System Overview
A 2-paragraph summary of the platform: a lab-scale national education
platform mirroring India's DIKSHA architecture. Mention the 15-container
Docker Compose stack, single NGINX entry point, and Keycloak SSO.
## 2. Component Map
A table with columns: Service | Container(s) | Purpose | DIKSHA/Sunbird Equivalent
Include every service:
- NGINX (reverse proxy + edge cache) = API Gateway / CDN origin
- Moodle (nginx + PHP-FPM + MariaDB 11.8 + Valkey 9) = Sunbird Lern
- Keycloak 26.6.0 + PostgreSQL 17.2 = Sunbird Identity
- Jitsi Meet (web + prosody + jicofo + JVB, stable-10888) = Live video conferencing
- MediaCMS (web + celery_worker + celery_beat + migrations + PostgreSQL + Redis) = Sunbird Knowlg
- Prometheus v3.11.2 + Grafana + cAdvisor = Sunbird Obsrv
- Pa11y CI 4.1.0 = Accessibility telemetry
## 3. Architecture Diagrams
Embed the four Mermaid diagrams from docs/ inline:
- System topology (all 15 containers, Docker network, NGINX entry point, monitoring loop)
- SSO dataflow (OIDC authorization code flow)
- Video delivery workflow (HLS pipeline with cache layers)
- Observability pipeline (cAdvisor -> Prometheus -> Grafana)
## 4. Technology Choices
For each major technology (Moodle, Keycloak, Jitsi, MediaCMS, NGINX,
Prometheus, Grafana, cAdvisor, Pa11y CI), write 2-3 sentences explaining
why it was chosen and what alternative was considered.
## 5. Monitoring Stack
Explain how Prometheus + Grafana + cAdvisor provides the observability
layer. Describe the three scrape jobs (cadvisor, nginx, prometheus self).
Compare to DIKSHA's Sunbird Obsrv which uses a Kafka-based telemetry
pipeline with Apache Druid and Superset for analytics dashboards.
What does this prompt produce?
Cursor generates a Markdown file with five sections that document your entire stack. The component map table is the most important part. It creates a one-to-one mapping between your lab services and the real Sunbird building blocks that power DIKSHA.
The monitoring section compares your Prometheus + Grafana + cAdvisor stack to Sunbird Obsrv. Sunbird Obsrv uses a Kafka-based telemetry pipeline with Apache Druid for analytics and Superset for dashboards. Your stack achieves the same goal (real-time container observability) with simpler tools that are appropriate for a lab environment.
- Review the generated docs/architecture.md file.
- Verify that the component map includes all 15 containers and that each one has a Sunbird equivalent listed.
- Confirm the four Mermaid diagrams from Step 5 are embedded inline using ```mermaid code blocks.
Pro tip: cross-check the mappings
The key mappings to verify are: Moodle = Sunbird Lern (learning journeys, enrolment, course management), MediaCMS = Sunbird Knowlg (content service, video players, HLS streaming), Keycloak = Sunbird Identity (federated SSO, realm-per-state model), and Prometheus + Grafana + cAdvisor = Sunbird Obsrv (telemetry, data processing, analytics dashboards). If Cursor used different names, correct them.
Cursor generated incomplete sections?
If any section is missing or truncated, send a follow-up prompt asking Cursor to complete the specific section. Large documents sometimes hit context limits. You can also split the prompt into two: sections 1-3 first, then sections 4-5.
Still having trouble?
Help me debug why Cursor is not generating the full architecture document.
Add the Karnataka deployment scenario
Your lab stack runs on a single laptop. A real deployment for one Indian state needs to handle millions of concurrent users across thousands of schools. This section models what that looks like.
- Send the following prompt to Cursor Composer to add the deployment section to your architecture document:
Add a new section to docs/architecture.md:
## Real-World Deployment: Karnataka, India
Model a production deployment serving 10 million students across
60,000 schools in Karnataka state.
Reference these verified DIKSHA data points:
- DIKSHA serves all 36 Indian states and UTs
- 5.3 billion+ total learning sessions
- 8 million monthly active users, 700K daily active users
- 19,698 courses, 182.3 million enrolments
- 30+ languages supported
- Built on Sunbird, an MIT-licensed microservices stack with 100+ building blocks
- Infrastructure: Docker, Kafka, Elasticsearch, Redis, PostgreSQL, NGINX
Include these subsections:
### Infrastructure Layout
- 3 availability zones in a single cloud region
- Cloudflare CDN for static assets and HLS video segments
- Load balancer distributing traffic across zones
### Horizontal Scaling Plan
- Moodle: 3+ PHP-FPM replicas behind a load balancer, shared MariaDB with read replicas
- Jitsi: JVB sharding (one JVB per AZ, Ocastrator for load distribution)
- MediaCMS: dedicated transcoding worker pool (scale celery workers independently)
- Keycloak: clustered with JDBC_PING2 discovery (no multicast needed)
### Identity Federation
- One Keycloak realm per state
- Identity brokering between state realms and a central realm
- Maps to DIKSHA's federated model where each state manages its own identity pool
### Observability at Scale
- Prometheus federation: one Prometheus per AZ, a global Prometheus aggregating
- Thanos for long-term metric storage in object storage (S3/GCS)
- Grafana querying Thanos for historical data and local Prometheus for real-time
### Estimated Monthly Cloud Cost
- Provide a range estimate (e.g., $15,000-$25,000/month) for compute,
storage, CDN, and database services
- Note that DIKSHA's total budget was approximately 577.27 million INR
(~$7M USD) through 2023-24
Why Karnataka specifically?
Karnataka is one of India's most digitally advanced states. It was an early adopter of DIKSHA, launching energized textbooks with QR codes in 2018 and crossing 1 crore (10 million) content plays by 2020. Modeling a deployment for Karnataka gives you a realistic target with a state that has existing digital infrastructure and high adoption rates.
- Review the generated section in docs/architecture.md.
- Verify that the DIKSHA data points match the verified figures: 5.3 billion+ learning sessions, 8 million MAU, 19,698 courses, 182.3 million enrolments.
- Confirm the horizontal scaling plan covers all four services: Moodle replicas, JVB sharding, MediaCMS transcoding workers, and Keycloak clustering with JDBC_PING2.
- Confirm the observability section mentions Prometheus federation with Thanos for long-term metric storage.
What is Thanos?
Thanos extends Prometheus for long-term storage and high availability. It allows multiple Prometheus instances to push metrics to object storage (like S3 or GCS), then provides a unified query layer across all of them. In a multi-AZ deployment, each availability zone runs its own Prometheus. Thanos aggregates their data so Grafana can query historical metrics across the entire infrastructure from a single endpoint.
DIKSHA numbers look different from what Cursor generated?
Cursor may pull different figures from its training data. The verified numbers from official sources are: 5.3 billion+ learning sessions (dpi.global), 8 million MAU and 700K DAU (ed.sunbird.org, Dec 2021), 19,698 courses with 182.3 million enrolments (UNESCO), and 30+ languages. Correct any discrepancies manually.
Help me verify the correct DIKSHA scale numbers for my architecture document.
Add the industry evaluation and quality improvements
Honest self-evaluation separates a learning project from a portfolio piece. You will rate your platform against five production dimensions using evidence from your actual build.
- Send the following prompt to Cursor Composer to add the evaluation section:
Add a new section to docs/architecture.md:
## Industry Evaluation and Quality Improvements
Rate the platform against five production dimensions. For each dimension,
assign a score from 1 (not addressed) to 5 (production-ready) with
justification using evidence from the build.
### 1. Security (Score: 2/5)
- Evidence: Keycloak provides OIDC SSO, but all traffic runs over HTTP
(no TLS). Credentials are stored in plain text in docker-compose.yml.
MediaCMS has no OIDC integration (separate login).
- Gap: No TLS termination, no secrets management, no WAF.
### 2. Scalability (Score: 2/5)
- Evidence: All services run as single instances on one host.
Docker Compose does not support multi-node orchestration.
- Gap: No horizontal scaling, no container orchestration (Kubernetes),
no database replication.
### 3. Reliability (Score: 2/5)
- Evidence: Health checks exist on databases. Docker restart policies
provide basic recovery.
- Gap: No redundancy, no failover, no backup strategy, single points
of failure everywhere.
### 4. Accessibility (Score: 3/5)
- Evidence: Pa11y CI audit ran against all three front-ends with
WCAG 2.1 AA standard. Results documented in validation report.
- Gap: Audit covers automated checks only. Manual testing (keyboard
navigation, screen reader compatibility) not performed.
### 5. Observability (Score: 4/5)
- Evidence: Live Grafana dashboard showing real-time CPU, memory,
and network metrics for all 15 containers. Prometheus scraping
cAdvisor and NGINX stub_status every 15 seconds. Load test
demonstrated visible metric changes on the dashboard.
- Gap: No log aggregation (ELK/Loki), no distributed tracing,
no alerting rules configured.
### Five Concrete Next-Step Improvements
1. Add TLS termination with Let's Encrypt certificates on NGINX
2. Migrate to Kubernetes for horizontal scaling and self-healing
3. Add Loki for centralized log aggregation alongside Prometheus metrics
4. Implement MediaCMS OIDC integration using django-allauth for
true single sign-on across all three platforms
5. Configure Prometheus alerting rules with Alertmanager for
automated incident notification
Why does observability score highest?
Your monitoring stack is the most production-like component in the entire platform. You have a live Grafana dashboard with real-time container metrics, automated scraping every 15 seconds, and you proved it works under load. That is fundamentally different from just running docker logs. The gap to production is adding log aggregation and alerting, which are extensions of what you already have.
- Review the generated evaluation section in docs/architecture.md.
- Adjust any scores based on your actual build results. If your Pa11y audit found more than 50 errors, lower the Accessibility score. If you configured restart policies on all services, you can justify a slightly higher Reliability score.
- Save docs/architecture.md.
What is the MediaCMS OIDC gap?
MediaCMS does not have built-in OIDC support. Full SSO would require a custom Django authentication backend using a library like django-allauth or python-social-auth. In your lab, MediaCMS uses its own admin login, which means it is the one service where single sign-on does not work. This is an honest limitation documented in the evaluation, and fixing it is listed as one of the five next-step improvements.
Awesome, I've got everything!
Double check that you have saved docs/architecture.md with all three sections: the system overview with component map, the Karnataka deployment scenario, and the industry evaluation.
I'd like to double check the full document
Your final docs/architecture.md should contain these major sections in order:
# EdTech Platform Architecture
## System Overview
<!-- 2-paragraph summary of the platform -->
## Component Map
<!-- Table: Service | Container(s) | Purpose | DIKSHA/Sunbird Equivalent -->
<!-- All 15 containers listed -->
## Architecture Diagrams
<!-- Four embedded Mermaid diagrams: -->
<!-- 1. System topology -->
<!-- 2. SSO dataflow -->
<!-- 3. Video delivery workflow -->
<!-- 4. Observability pipeline -->
## Technology Choices
<!-- Rationale for each major technology -->
## Monitoring Stack
<!-- Prometheus + Grafana + cAdvisor explanation -->
<!-- Comparison to Sunbird Obsrv -->
## Real-World Deployment: Karnataka, India
<!-- 10M students, 60K schools -->
<!-- Infrastructure layout (3 AZs, Cloudflare CDN) -->
<!-- Horizontal scaling plan -->
<!-- Identity federation (realm-per-state) -->
<!-- Observability at scale (Prometheus federation + Thanos) -->
<!-- Estimated monthly cloud cost -->
## Industry Evaluation and Quality Improvements
<!-- Five dimensions scored 1-5: -->
<!-- Security (2/5), Scalability (2/5), Reliability (2/5) -->
<!-- Accessibility (3/5), Observability (4/5) -->
<!-- Five concrete next-step improvements -->
Your architecture document is complete. It maps every container to its DIKSHA equivalent, models a real-world deployment for 10 million students, and honestly evaluates where your lab ends and production begins. This is the kind of documentation that turns a hands-on project into a portfolio piece.
Secret mission
Build a DIKSHA-Equivalent Proof of Concept with Multi-State Federation and Custom Monitoring
India's DIKSHA platform federates identity across 36 states, each managing its own pool of teachers and students. Your current stack has a single Keycloak realm. Can you simulate multi-state federation, build a custom monitoring dashboard from scratch, and produce a production readiness scorecard that proves your platform could scale to serve a real country?
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on Docker Desktop, so there are no cloud costs or ongoing charges.
Resources you used:
- NGINX reverse proxy container (port 80).
- Moodle stack: nginx, PHP-FPM, MariaDB 11.8, and Valkey 9 containers.
- Keycloak 26.6.0 container and its PostgreSQL 17.2 database.
- Jitsi Meet stack: web, prosody, jicofo, and JVB containers (stable-10888).
- MediaCMS stack: web, celery_worker, celery_beat, migrations, PostgreSQL, and Redis containers.
- Monitoring stack: Prometheus, Grafana, and cAdvisor containers.
- Docker volumes for all databases (MariaDB, both PostgreSQL instances, Redis, Valkey).
- The edtech-platform/ project directory with all config files, scripts, diagrams, and documentation.
- Pa11y CI installed globally via npm.
Keep everything running
No action needed. Choose this if you are still actively experimenting with the stack, testing SSO flows, or exploring the Grafana dashboards.
- The full stack uses approximately 8-10 GB of RAM when idle with monitoring running.
- All services remain accessible at their http://localhost paths.
- Prometheus continues collecting metrics and Grafana dashboards stay live.
Pause - I'll come back to this later
Shut down all containers to free up memory while keeping your data intact. You can resume exactly where you left off.
- Open a terminal in your edtech-platform/ directory.
- Run the following command to stop all 15 containers without deleting any data:
docker compose stop
- When you are ready to resume, run:
docker compose start
- All your Keycloak realms, Moodle courses, MediaCMS videos, Grafana dashboards, and Prometheus metrics will be preserved.
Delete - I don't want to use this again
Remove all containers, networks, and volumes. This permanently deletes all data including your Keycloak configuration, Moodle courses, uploaded videos, and Prometheus metrics.
- Open a terminal in your edtech-platform/ directory.
- Run the following command to remove everything:
docker compose down -v
- The -v flag removes all Docker volumes, which is where your databases and persistent data are stored.
- To also remove the project files from your machine, delete the edtech-platform/ directory.
- To uninstall Pa11y CI, run:
npm uninstall -g pa11y-ci
- Verify cleanup is complete by running docker ps -a to confirm no edtech containers remain.
Mission accomplished
That's a Wrap
Nice work. You just architected a national-scale education platform with a live monitoring control tower, all running on your laptop.
You've learned how to:
- Orchestrate a 15-container Docker Compose stack with Moodle, Jitsi Meet, MediaCMS, Keycloak, NGINX, Prometheus, Grafana, and cAdvisor all communicating through a single reverse proxy entry point.
- Configure OpenID Connect SSO with Keycloak so one login grants access to an LMS, embedded live video classrooms, and a video-on-demand library.
- Deploy a Prometheus + Grafana + cAdvisor monitoring stack that watches every container in real time, with NGINX edge caching validated through measurable X-Cache-Status headers and five benchmarked pass/fail metrics.
- Configure multi-state identity federation across Keycloak realms, build a custom Grafana dashboard, run dual-runner accessibility audits, and create a production readiness scorecard comparing your platform to DIKSHA's Sunbird architecture.
Ready to quiz yourself?