Idempotent Ansible playbooks that take a fresh Ubuntu LTS server to a running Moodle 5.x LMS for ROFEO Academy. I tested them on a dev VM on my homelab and on an Azure VM. In production it ran on a single server; ROFEO piloted it with teachers and students and no longer uses it.
The sanitized open-source distribution is published as rofeo-ansible-template. This document details the system architecture, process boundaries, state synchronization pipelines, and failure modes.
Subsystem Boundaries
I engineered the complete Ansible automation topology across nine roles: system_prep, firewall, postgres, redis, php, nginx, moodle, mailpit and backup. The main ones:
- Web Tier (
roles/nginx): Server blocks enforcing Moodle 5.x’s isolated/publicwebroot, FastCGI buffer allocations, loopback-only FPM metrics endpoints (/fpm-status), and automated Let’s Encrypt ACME renewal hooks. - Runtime & Process Pool (
roles/php,roles/moodle): Dedicated PHP 8.3-FPM pool isolation (moodle), Zend Opcache parameter tuning, native systemd timer units replacing legacy cron loops, and CLI-driven non-interactive core installations. - State & Persistence (
roles/postgres,roles/redis): PostgreSQL 16 database provisioning ontemplate0enforcing strict UTF-8/en_US collation, alongside a dual-database Redis deployment segregating ephemeral cache from stateful student sessions. - Plugin Lifecycle Pipeline: A declarative Git synchronization engine handling third-party and custom plugin deployments (
theme_rofeo,local_rofeo,theme_moove) paired with automated post-pull database migrations. - Disaster Recovery & Backup (
roles/backup): A disk-aware backup runner with Azure Blob offsite synchronization viarclone, accompanied by two recovery playbooks: targeted commit-state rollbacks and full bare-metal disaster recovery.
Architecture
The topology decouples application layers onto local Unix Domain Sockets (UDS) and loopback interfaces (127.0.0.1), eliminating inter-process network hops:
- Ingress: Public traffic terminates at Nginx over TCP 443 with TLS 1.3.
- FastCGI Transport: Nginx proxies PHP requests directly to PHP-FPM over
/run/php/php8.3-fpm-moodle.sockunderwww-data:www-datapermissions (0660). - Database & Cache: PHP workers communicate locally with PostgreSQL on TCP 5432 and Redis on TCP 6379 via authenticated loopback bindings.
- Cron & Background Tasks: Decoupled from web request loops entirely; a native systemd timer executes CLI workers in single-tenant batches.
State Partitioning & In-Memory Isolation
Moodle couples high-frequency relational reads/writes with heavy filesystem caching and volatile session state. The settings below were sized for concurrent exam traffic. The load figures in this section are design targets, not measurements from the pilot:
- Session Write Load: With sessions in PostgreSQL (
mdl_sessions), every active quiz participant generates continuous write transactions, and the design target was hundreds of participants at once. Sessions are offloaded entirely to Redis DB 1, off the database that holds the quiz data. - Dual-Database Segregation: Under
allkeys-lru, a burst of cache writes (invalidation, language string compilation) can evict session keys and log students out mid-quiz. Separate databases keep a cache purge away from sessions, but they don’t change eviction, which applies to the whole instance (see below). - Partitioned Boundaries: Redis DB 0 holds the Moodle Universal Cache (MUC) and DB 1 holds sessions, with explicit TTLs (7,200s) and lock timeouts (120s). A cache purge flushes DB 0 only, so purging caches no longer logs students out. Memory pressure is a different story: both databases share one instance, its 256MB cap and its
volatile-lrupolicy, which only evicts keys that have an expiry. Only the session keys have one, so they are the only keys Redis can evict, and once none are left it rejects new writes. What I’d change: give sessions their own instance withnoeviction, sized for the peak number of sessions, and keep the cache on a capped instance withallkeys-lru.
Runtime & Web Process Tuning
Default configurations favor lightweight, short-lived PHP requests. LMS workloads, such as gradebook exports, bulk CSV student enrollments, and SCORM package decompression, trigger heavy compute operations:
- FastCGI Buffer Tuning: Under standard Nginx timeouts (
fastcgi_read_timeout 60s), large report generations drop with504 Gateway Timeout. Nginx and PHP pool timeouts are aligned to 300 seconds, withclient_max_body_size 128Mto handle large course asset uploads. - Worker Sizing & Memory Recycling: The dedicated
moodleFPM pool runs dynamically (pm.max_children = 20) withpm.max_requests = 500. Recycling worker processes after 500 requests mitigates slow memory leaks introduced by complex third-party plugins. - Zend Opcache Sizing: The default Ubuntu limit of 10,000 files causes severe cache thrashing on Moodle 5.x (which contains over 12,000 individual PHP files).
opcache.max_accelerated_filesis tuned to 16,229 with 128MB shared memory andmax_input_varsraised to 5,000 to prevent silent form truncation on role permission matrices. - Systemd Cron Timers Over Legacy Crontab: Traditional
/etc/crontabexecutions lack process state awareness. If a scheduled cron job stalls due to a heavy background indexing task, subsequent minute-based cron triggers spawn on top of it, creating worker process storms. Deploying a nativeType=oneshotsystemd timer unit guarantees overlapping executions can never occur.
Declarative Plugin Lifecycle & Drift
Managing extensions via the web UI violates infrastructure immutability. The template manages plugins through the moodle_plugins declarative inventory array:
- Targeted Git Fetch: Ansible checks out each repository directly into its respective target path inside
/var/www/moodle/public/using pinned branches or tags (depth: 1,umask: 0022). - Conditional Schema Migrations: If and only if a plugin repository pulls new commit references, the playbook executes Moodle’s non-interactive CLI upgrade script (
admin/cli/upgrade.php --non-interactive) followed by an immediate cache purge (admin/cli/purge_caches.php). - Ansible Error Handling Quirks: Certain plugins emit non-fatal informational warnings to
stderrduring schema validation. In Ansible’s default mode, anystderrstream marks the task as failed even if the process exits with status code0. The playbook uses explicit output assertions ('System was upgraded successfully' in upgrade_result.stdout) to prevent false deployment aborts. - Permission Boundaries: The codebase is owned by
root:www-data(0755directories,0644files), restricting PHP-FPM workers to read-only access. Write permissions are quarantined strictly to/var/moodledata(0770owned bywww-data:www-data).
Disaster Recovery & Rollback Mechanics
A backup system that cannot perform rapid, automated rollbacks introduces catastrophic operational risk. The infrastructure implements two distinct restoration pipelines:
- Pre-Update State Rollbacks (
restore_moodle.yml): Before applying any plugin or core update,update_moodle.ymlrecords the active Git commit SHAs of core and all plugins, dumps the pre-upgrade PostgreSQL schema, and writes a metadata snapshot topre_update_state.yml. If an update fails, runningrestore_moodle.ymlreads this file, checks out the exact recorded Git SHAs, drops the database, restores the pre-upgrade SQL dump, and clears caches. Target RTO is under 2 minutes. Anything written after the pre-update dump is lost on rollback, so the data-loss window is the time since that dump. - Bare-Metal Disaster Recovery (
disaster_recovery.yml): Rebuilds a host from scratch by scanning local archives and remote Azure Blob Storage containers viarclone. The operator is prompted for a target snapshot ID; the playbook then terminates active database connections, drops and recreates the database withtemplate0/en_US.UTF-8collations, unpacks the dataroot archive with0770permissions, and rebuilds caches. - Defensive Backup Runner: The daily backup script (
moodle-backup.sh) runs a pre-flight disk check that aborts execution if partition usage exceeds 80%, preventing root volume exhaustion. It wraps execution intrap cleanup EXITbound to disabling Moodle maintenance mode, ensuring the site is never left locked if a dump process encounters a transient error.
Key Configuration Invariants (Tuning Matrix)
Nginx Subsystem
fastcgi_read_timeout: Default:60s→ Hardened:300s
Purpose: Prevents 504 timeouts during large grading exports and SCORM backups.client_max_body_size: Default:1M→ Hardened:128M
Purpose: Permits SCORM packages and course video uploads.server_tokens: Default:on→ Hardened:off
Purpose: Obfuscates web server version in HTTP response headers.
PHP-FPM Runtime & Pool
pm.max_children: Default:5→ Hardened:20
Purpose: Scales concurrent worker capacity.pm.max_requests: Default:0(Unlimited) → Hardened:500
Purpose: Recycles worker memory to mitigate plugin leaks.request_slowlog_timeout: Default: Disabled → Hardened:10s
Purpose: Isolates slow database queries to dedicated logs.
PHP Core Directives
memory_limit(FPM / CLI): Default:128M→ Hardened:512M / 1024M
Purpose: Accommodates heavy quiz evaluation matrices and CLI migrations.max_input_vars: Default:1000→ Hardened:5000
Purpose: Prevents silent truncation of complex gradebook matrices.upload_max_filesize: Default:2M→ Hardened:100M
Purpose: Aligns file upload threshold with institutional requirements.opcache.max_accelerated_files: Default:10000→ Hardened:16229
Purpose: Caches all ~12,000 Moodle 5.x PHP files in RAM without thrashing.
PostgreSQL & Redis Persistence
- PostgreSQL Collation: Default: OS Default (
C) → Hardened:en_US.UTF-8ontemplate0
Purpose: Prevents database corruption on multilingual text sorting. - Redis Eviction Policy: Default:
noeviction→ Hardened:volatile-lru
Purpose: Under memory pressure, evicts the least recently used keys that have an expiry. Here that means session keys, since cache keys have none; once no session keys are left, Redis rejects new writes. - Redis Partitioning: Default: DB
0(Shared) → Hardened: DB0(Cache) / DB 1 (Sessions)
Purpose: Eliminates session drops during course cache purges.
Moodle Core & Filesystem
- Session Handler: Default: Database (
mdl_sessions) → Hardened:\core\session\redis
Purpose: Offloads session I/O, stopping database connection spikes. - Cron Execution: Default: Web allowed (
0) → Hardened: CLI Only (1)
Purpose: Blocks unauthorized external triggers of cron via web HTTP requests. - Dataroot Permissions: Default:
0755→ Hardened:0770(www-data:www-data)
Purpose: Locks student submissions exclusively to the runtime process user.