
When designing IT infrastructure for higher education, the default choice for the past decade has been handing over data to massive SaaS providers. But as budget constraints tighten and data privacy concerns (GDPR, student data sovereignty) become critical, universities and independent educators are re-evaluating on-premise or privately hosted cloud solutions.
I’ve spent years deploying massive infrastructure. Honestly, the barrier to entry for self-hosting has completely evaporated thanks to containerization. Today, I want to talk about leveraging a HomeLab environment鈥攐r a dedicated bare-metal server鈥攖o self-host an entire educational technology stack using Docker.
I initially thought managing a self-hosted LMS (Learning Management System) would be a maintenance nightmare because of database migrations and PHP dependencies. But it failed horribly only when I tried to install everything directly on a Linux VM. Once you wrap these services in Docker, the paradigm shifts entirely.
Let’s build a functional, production-ready stack. We need:
Here is the exact docker-compose.yml structure I use when prototyping a new department-level LMS deployment. Notice how we isolate the database and cache from public access using an internal Docker network.
version: '3.8'
services:
mariadb:
image: bitnami/mariadb:10.11
environment:
- MARIADB_USER=moodle_user
- MARIADB_PASSWORD=super_secure_password
- MARIADB_DATABASE=moodle_db
- MARIADB_ROOT_PASSWORD=root_secure_password
volumes:
- mariadb_data:/bitnami/mariadb
restart: always
networks:
- edtech_net
redis:
image: bitnami/redis:7.0
environment:
- ALLOW_EMPTY_PASSWORD=yes
restart: always
networks:
- edtech_net
moodle:
image: bitnami/moodle:4.2
ports:
- "8080:8080"
- "8443:8443"
environment:
- MOODLE_DATABASE_HOST=mariadb
- MOODLE_DATABASE_PORT_NUMBER=3306
- MOODLE_DATABASE_USER=moodle_user
- MOODLE_DATABASE_PASSWORD=super_secure_password
- MOODLE_DATABASE_NAME=moodle_db
- MOODLE_CACHE_DRIVER=redis
- MOODLE_REDIS_HOST=redis
volumes:
- moodle_data:/bitnami/moodle
- moodledata_data:/bitnami/moodledata
depends_on:
- mariadb
- redis
restart: always
networks:
- edtech_net
volumes:
mariadb_data:
moodle_data:
moodledata_data:
networks:
edtech_net:
driver: bridge
Running Moodle on port 8080 internally is fine, but exposing it raw to the internet is not. We place an Nginx container in front that handles HTTPS termination and enforces secure headers:
server {
listen 80;
server_name lms.yourdomain.edu;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name lms.yourdomain.edu;
ssl_certificate /etc/letsencrypt/live/lms.yourdomain.edu/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/lms.yourdomain.edu/privkey.pem;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
client_max_body_size 512M;
location / {
proxy_pass http://moodle:8080;
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;
proxy_read_timeout 300s;
}
}
The client_max_body_size 512M line is a frequent source of confusion. Moodle’s own PHP upload limit must match this value 鈥?set upload_max_filesize and post_max_size accordingly in php.ini, otherwise students uploading large video assignments will get cryptic 413 errors.
1. Permissions and Volumes
If you just spin this up on a raw Ubuntu server, you might hit permission denied errors on the moodledata_data volume. Bitnami images run as a non-root user (usually UID 1001). You have to ensure the host directory has the correct ownership before first boot.
sudo chown -R 1001:1001 /path/to/your/docker/volumes
2. Handling Real-Time Traffic Spikes
During my first large-scale pilot test at a regional institution, the system crashed during a concurrent quiz submission. The bottleneck wasn’t CPU 鈥?it was PHP-FPM workers getting exhausted waiting on database locks.
The fix? Aggressive Redis caching. The MOODLE_CACHE_DRIVER=redis variable in the compose file above is not optional for production. It shifts session management and course structure caching directly into RAM. For a deep dive into sizing PHP-FPM worker pools correctly for your server’s RAM, see our detailed guide on Moodle Performance Tuning: PHP-FPM Workers, Redis Cache, and OPcache.
3. Database Slow Query Log
MariaDB’s slow query log is your best friend when diagnosing Moodle hangs. Enable it by adding these flags to your MariaDB environment block:
- MARIADB_EXTRA_FLAGS=--slow-query-log=1 --slow-query-log-file=/tmp/slow.log --long-query-time=2
Queries taking more than 2 seconds almost always point to a missing database index. Moodle’s built-in environment checker (/admin/index.php) will flag most of these, but production load sometimes reveals additional gaps.
Once the LMS is containerized, your HomeLab or cloud instance becomes a playground. You can easily add services like Nextcloud (for secure document distribution replacing Google Drive), BigBlueButton (for open-source video conferencing), or Gitea (for computer science students to submit code).
For production deployments serving hundreds of concurrent users, add a monitoring layer early. Our article on Prometheus and Grafana for LMS Performance Monitoring walks through the exact PromQL queries and alert rules we use to detect PHP-FPM saturation and database connection pool exhaustion before they become outages.
The digital transformation of education isn’t just about buying new software; it’s about reclaiming the infrastructure. By standardizing on Docker, institutions can deploy complex, resilient environments in minutes rather than months.
In the next post of this Dev Log, I’ll walk through how we can write a custom Python script to automatically synchronize student rosters from a legacy SIS (Student Information System) directly into our new containerized LMS using its REST API. If you need a step-by-step installation walkthrough including SSL and database setup from scratch, our guide on Installing Moodle on Ubuntu 22.04 with Docker Compose picks up exactly where this post leaves off.