LY

Learning Management System Pilot — Deployment to Full Reconciliation

automation, infrastructure

Oct 08, 2026 18:30 UTC

Learning Management System Pilot — Deployment to Full Reconciliation

Rolled out a pilot of Moodle, an open-source learning management system, for one school branch within a larger multi-campus education group — the same self-hosted-first instinct behind the HR automation project, applied to the classroom side instead of HR records. This write-up covers the build, a couple of real near-misses, and the slow process of turning a set of empty named containers into an actually-correct roster.

The stack. Moodle deployed via Docker Compose (the app container, a PostgreSQL database, and Moodle's own background cron) on a dedicated VPS from a cloud provider, isolated from other infrastructure the same way the HR system's VPS is — a pattern that's becoming the default for anything holding real personal data belonging to the organization rather than personal infrastructure.

First pass at structure. Built a category tree mirroring the school's own hierarchy — a parent organization category, a branch category for the pilot campus, then one category per grade level, with one course shell per class section underneath. Each shell got a one-line summary (homeroom teacher, roster size) pulled from an existing attendance-tracking database already in use elsewhere for staff reporting.

The data-freshness problem, caught before it mattered. That attendance database turned out to be stale — months out of date, reflecting the previous school year's class assignments. A second, live source existed (an online registration system, current to the day) but only had grade-level data, no section/homeroom detail, and the school's academic year had just rolled over with a full promotion cycle — so the class-shell structure built from the stale source was almost certainly wrong for anyone it actually got used on. Caught this before any real student or teacher account existed, which made it a non-event rather than a cleanup job: nothing got bulk-imported until an actual current section list arrived directly from the school.

Branding, and a CMS surprise. Set the site logo and a short beta-announcement banner on the login page by writing a small one-off script that runs through Moodle's own bootstrap, using the same file-storage and config APIs its admin settings UI uses internally rather than touching the database directly. Updating the brand color later (to match the real logo rather than a generic placeholder blue) surfaced something unexpected: this Moodle release ships a built-in AI-generated login background image feature that overrides a plain brand color by default, complete with a small "AI-generated image" watermark — independent of, and silently overriding, the normal background-image admin setting. Had to explicitly suppress both the generated image and its watermark via CSS, on top of the actual color change. Also ran into Moodle's theme cache being revision-keyed rather than content-hashed: rapid edits within the same second can serve a stale cached stylesheet even after an explicit cache-clear, because the revision number didn't actually increment. Verified the fix by curling the compiled stylesheet directly rather than trusting a reload.

The bulk import. Once a real, current section list arrived from the school, ran a one-off script against Moodle's own user-creation and enrolment APIs (not raw SQL, to keep role assignments and enrolment records internally consistent) to create every teacher and student account at once — several dozen teachers with real school email addresses, and well over a thousand students who had no email or ID in the source file at all. Students got a placeholder email address and a sequential temporary ID standing in for their real school-issued student ID, a shared temporary password, and a forced password-change on first login. The sequential placeholder ID was deliberate: a stable join key to swap in the real student ID later without having to re-match names from scratch.

Role assignment doesn't work the way it looks like it should. Asked to grant all homeroom teachers broader access across the whole grade-level category (not just their own class), which led to a genuinely useful discovery: Moodle enforces actual course enrolment before a student can see anything, so a broad role grant for students is functionally inert — but for teachers, edit-capable roles cascade through context inheritance regardless of enrolment, so the same kind of broad grant gives real, live edit access to every class in the category, not just the teacher's own. Walked this through before it became a problem, and rolled the broad grant back to each teacher's own class only, once the asymmetry was clear. Also spent a while mapping where Moodle actually surfaces role-assignment screens for each scope (system-wide vs. category vs. course) — the category-level one in particular only appears on one specific page, not on the main course-management screen, something not obvious from the UI and only confirmed by reading the admin interface's own navigation source.

ID reconciliation, in rounds. The school sent real student IDs to replace the sequential placeholders, in a spreadsheet that was row-aligned with the original import order — useful, but not perfect: a meaningful chunk of rows had either a literal "not found" value or the same ID assigned to two different students (mostly sibling pairs, evidently a copy-paste error on the school's side). Flagged the problem rows back to the school rather than guessing, applied the clean majority, and left the rest on their placeholder IDs. Took three more rounds of corrected data from the school — each one closing most but not all of the remaining gaps, including one case where a freshly-issued "fixed" ID for one student collided with a completely different student's existing one — before every account finally carried its real school ID. One extra case surfaced during this process: a student who appeared on the school's own roster but had never been included in any data file sent so far, not a duplicate or typo, just a genuine gap in what had been exported. Created that one account directly rather than waiting on another file.

Restructuring from one course per class to one category per class. The original "one course shell = one class" model stopped making sense once actual subject-level courses needed to exist underneath each class rather than one container representing the whole thing. Converted every class from a single course into its own category containing one course per subject, carrying over its existing roster and teacher onto each new subject course rather than re-enrolling from scratch. Piloted the conversion on a single class first, got it checked against the live site before running the same logic as a batch across every remaining class. The batch run picked up one extra class that had quietly changed from hidden to visible between the pilot and the batch run, for reasons unrelated to this work — caught and flagged rather than silently absorbed, since it held a couple of pre-existing unrelated test accounts.

Real curriculum, replacing a four-subject placeholder. The initial per-class subject split used a generic stand-in list; once the school sent its actual curriculum breakdown by grade band, rebuilt every class's subject courses from that instead — different grade bands get different subject counts and names, with a couple of subjects deliberately merged or substituted depending on band per direct confirmation from the school rather than assumption. One subject area stayed intentionally empty of students (teacher-only) pending a separate roster that hadn't arrived yet at that point. The rebuild ran as one script per class, old placeholder subjects deleted and real ones created in the same pass, verified against the database directly afterward rather than trusting the script's own run-time output — it ran long enough that the process-monitoring layer briefly reported it as stalled when it had, in fact, finished correctly.

A human-readable course code. Added a structured code to every course — encoding school, grade, class section, and subject into one short identifier — after the first pass (just reusing the internal course shortname) wasn't the numeric scheme actually wanted. Exported the final mapping as a spreadsheet for the school's own reference.

A late roster, and a casing bug. A separate roster for one specific elective subject arrived later, covering a subset of classes rather than all of them. The first enrolment pass silently under-matched — a class-label parser produced mixed-case identifiers that didn't match the system's own all-caps naming convention for most classes, so only a small fraction of the roster actually landed before the mismatch was caught by checking the enrolled count against the expected one, not by any error being thrown. Fixed the casing and re-ran; the script was written to skip anyone already enrolled, so the partial first run caused no duplicates.

Where it stands now: every class in the pilot branch is fully structured by subject, every student and teacher account is reconciled against the school's real records with no outstanding placeholder IDs, and the elective roster is fully enrolled. Still open: a second school branch hasn't been started, and the subject-course model hasn't been tested yet against a grade band outside the pilot's own curriculum data.

← all posts