Files
computer-playbook/roles/web-app-moodle/README.md
T
kevinveenbirkenbachandClaude Opus 5.5 0c748f74ed fix(web-app-moodle): swap the code tree only after a complete copy
The entrypoint removed the old tree before it copied the new one. A
copy that failed left the volume without a working Moodle, and every
restart removed again instead of recovering. It now copies into a
staging directory inside the volume and swaps the trees once the copy
is complete, so a failed copy leaves the old tree in place.

An image that ships an older release than the volume records no longer
replaces it and exits with a message instead. Two instances on
different releases would otherwise overwrite each other's tree.

upgrade.php reports "No upgrade needed" on stderr, so the task counted
every run on a current site as a change. It reads stderr now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 10:45:29 +02:00

12 KiB

Moodle

Description

Ignite the learning experience with Moodle, a powerful and versatile platform for online education that energizes classrooms and fosters interactive learning. Moodle delivers a comprehensive set of tools for creating, managing, and sharing educational content, supporting collaboration among educators and learners alike.

Overview

This role deploys Moodle using Docker, automating the setup of both the Moodle application and its underlying MariaDB database. It integrates with an NGINX reverse proxy to ensure secure and efficient web access and uses persistent storage to safeguard your data and configuration.

Cosmos

The diagram places Moodle in the Infinito.Nexus cosmos: the components it deploys (capabilities), the central services it consumes (dependencies), and its outward reach (federation and bridged external networks).

flowchart LR
    subgraph deps [Dependencies]
        dep_svc_ai_litellm["svc-ai-litellm 🐳🐝"]
        dep_svc_bkp_volume_2_local["svc-bkp-volume-2-local 💻"]
        dep_svc_db_mariadb["svc-db-mariadb 🐳🐝"]
        dep_svc_db_openldap["svc-db-openldap 🐳🐝"]
        dep_svc_net_tor["svc-net-tor 🐳🐝"]
        dep_web_app_dashboard["web-app-dashboard 🐳🐝"]
        dep_web_app_hermes["web-app-hermes 🐳🐝"]
        dep_web_app_keycloak["web-app-keycloak 🐳🐝"]
        dep_web_app_matomo["web-app-matomo 🐳🐝"]
        dep_web_app_openclaw["web-app-openclaw 🐳🐝"]
        dep_web_app_openwebui["web-app-openwebui 🐳🐝"]
        dep_web_app_prometheus["web-app-prometheus 🐳🐝"]
        dep_web_app_stalwart["web-app-stalwart 🐳🐝"]
        dep_web_svc_css["web-svc-css 💻"]
        dep_web_svc_logout["web-svc-logout 🐳🐝"]
    end
    subgraph role [web-app-moodle 🐳🐝]
        svc_litellm["litellm"]
        svc_sso["sso"]
        svc_logout["logout"]
        svc_dashboard["dashboard"]
        svc_matomo["matomo"]
        svc_email["email"]
        svc_ldap["ldap"]
        svc_mariadb["mariadb"]
        svc_moodle["moodle"]
        svc_nginx["nginx"]
        svc_cron["cron"]
        svc_css["css"]
        svc_prometheus["prometheus"]
        svc_tor["tor"]
        svc_container_backup["container_backup"]
        svc_openwebui["openwebui"]
        svc_hermes["hermes"]
        svc_openclaw["openclaw"]
        svc_flowise["flowise ❌"]
    end
    dep_svc_ai_litellm -. "0..1" .-> svc_litellm
    dep_svc_bkp_volume_2_local -. "0..1" .-> svc_container_backup
    dep_svc_db_mariadb -. "0..1" .-> svc_mariadb
    dep_svc_db_openldap -. "0..1" .-> svc_ldap
    dep_svc_net_tor -. "0..1" .-> svc_tor
    dep_web_app_dashboard -. "0..1" .-> svc_dashboard
    dep_web_app_hermes -. "0..1" .-> svc_hermes
    dep_web_app_keycloak -. "0..1" .-> svc_sso
    dep_web_app_matomo -. "0..1" .-> svc_matomo
    dep_web_app_openclaw -. "0..1" .-> svc_openclaw
    dep_web_app_openwebui -. "0..1" .-> svc_openwebui
    dep_web_app_prometheus -. "0..1" .-> svc_prometheus
    dep_web_app_stalwart -. "0..1" .-> svc_email
    dep_web_svc_css -. "0..1" .-> svc_css
    dep_web_svc_logout -. "0..1" .-> svc_logout

Solid 1:1 edges are fixed relationships; dashed 0..1 edges are conditional (enabled only in matching deployments); red 0..0 edges are turned off in this role. Node markers show the role's deploy modes (💻 host, 🐳 compose, 🐝 swarm); ❌ marks a service that is explicitly turned off, and ⚙️ an Ansible role dependency declared in meta/main.yml.

Features

  • Comprehensive e-Learning Platform: Offers an extensive array of features including course management, assessment tools, and collaborative resources.
  • Customizable Interface: Tailor the look and feel of your learning environment with numerous themes and plugins.
  • Scalable Deployment: Leverage Docker for a portable and scalable installation that adapts as your user base grows.
  • Robust Data Management: Secure and reliable storage of both the Moodle application and user data through Docker volumes.
  • Secure Web Access: Configured to work seamlessly behind an NGINX reverse proxy for enhanced security and performance.
  • Single Sign-On (SSO) / OpenID Connect (OIDC): Seamless integration with external identity providers for centralized authentication.

Quick Setup

Development

Clone, set up the workstation, and deploy Moodle onto the local stack:

git clone https://github.com/infinito-nexus/core.git
cd core
make onboard
make compose-deploy mode=reinstall apps=web-app-moodle full_cycle=false

Production

Run the published image to provision the inventory and deploy Moodle to a managed server (the mounted volume persists the inventory):

APP=web-app-moodle
HOST=<your-server>
DOMAIN=<your-domain>
TLS_MODE=self_signed
SSH_PUBLIC_KEY="<your-ssh-public-key>"

docker run --rm -it \
  -v "$PWD/inventories:/etc/infinito.nexus/inventories" \
  -e APP="$APP" -e HOST="$HOST" -e DOMAIN="$DOMAIN" -e TLS_MODE="$TLS_MODE" -e SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \
  ghcr.io/infinito-nexus/core/debian bash -c '
    INVENTORY=/etc/infinito.nexus/inventories/production
    infinito administration inventory provision "$INVENTORY" \
      --inventory-file "$INVENTORY/devices.yml" \
      --host "$HOST" \
      --include "$APP" \
      --vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" &&
    infinito administration deploy dedicated "$INVENTORY/devices.yml" \
      --password-file "$INVENTORY/.password" \
      --diff -vv'

MCP server

The role exposes a Model Context Protocol surface through the webservice_mcp protocol plugin, baked into the image at public/webservice/mcp and served by the NGINX sidecar.

Property Value
Endpoint /webservice/mcp/server.php on the canonical domain
Container URL http://nginx:80/webservice/mcp/server.php on the shared MCP overlay
Transport Streamable HTTP (JSON-RPC 2.0 over POST)
Auth Authorization: Bearer <token>, a Moodle web-service token
Subject the administrator account
Default state off; mcp.enabled turns true when web-app-hermes, web-app-openclaw or web-app-openwebui is in the inventory

Provisioning runs in tasks/utils/mcp.yml: it copies the plugin into the code volume, runs admin/cli/upgrade.php, enables enablewebservices, appends mcp to webserviceprotocols, creates the infinito_mcp external service, attaches the read-only functions, mints the permanent token and stores it under users.administrator.tokens['web-app-moodle'].

Tool categories exposed by the external service:

  • site metadata (core_webservice_get_site_info)
  • course catalogue (core_course_get_courses, core_enrol_get_users_courses)
  • user lookup (core_user_get_users_by_field)
  • calendar events (core_calendar_get_calendar_events)

All of them are read-only; mcp.tools.mutating_tools_enabled is false and no write function is attached.

Default state

Off. mcp.enabled is true only while web-app-hermes, web-app-openclaw or web-app-openwebui is part of the deployment.

Authorization subject

auth_subject: administrator: the web-service token is issued against the administrator account, so a call carries that account's rights regardless of who asked the client. The attached functions are read-only, which bounds what those rights can do here. Reaching the tool server is gated on the role's mcp RBAC group.

Canonical origin

Moodle compares every request against $CFG->wwwroot. A probe that reaches the endpoint over the internal HTTP origin is answered with a redirect to the canonical HTTPS URL rather than with JSON, so callers must send the canonical Host header and X-Forwarded-Proto: https.

How to disable

Remove the MCP client roles, or pin mcp.enabled: false for this role. The web-service token is then not issued and the protocol plugin stays unconfigured.

Outbound HTTP policy

Moodle ships a cURL blocklist that rejects every request to 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16, and only permits ports 80 and 443. Both settings gate \core\files\curl_security_helper, which auth_oidc consults for the Keycloak token exchange and \core\http_client consults for the AI provider request. The gateway answers on 192.168.80.0/24, and on an onion node container_extra_hosts pins the Keycloak authority to the Docker bridge gateway, which NETWORK_DOCKER_ADDRESS_POOLS draws from 10.208.0.0/12. With the upstream defaults the OIDC login obtains no token and the AI provider cannot reach the gateway.

tasks/01_manager_ops.yml therefore rewrites curlsecurityblockedhosts to MOODLE_EGRESS_BLOCKED_HOSTS and extends curlsecurityallowedport with the port of MOODLE_LITELLM_CHAT_ENDPOINT when the litellm service is enabled. Loopback, 0.0.0.0 and the cloud metadata address stay blocked; the RFC1918 ranges are opened. The Docker address pool and the bridge gateway that serves the pinned Keycloak authority both live inside them.

This is an accepted trade-off, not an oversight. Moodle offers no allowlist, so a single internal host cannot be permitted without unblocking the range it sits in. The cost is that any account holding a URL-fetching capability, such as block/rss_client:myaddinstance or a repository/url instance, can make the server issue requests to sibling containers. Grant those capabilities only to roles you trust with that reach, or keep the upstream blocklist and accept that SSO login and the AI surface do not work.

Image source

This role builds its own Moodle image from upstream Moodle source on top of the official php:8.3-fpm base and installs the Composer dependencies that source declares. The NGINX sidecar serves the public directory of the code volume and hands every other path to Moodle's router.

Updates

The image carries the Moodle release pinned as services.moodle.app_version in meta/services.yml. On start the entrypoint compares that release with the one recorded in the code volume. When the image is newer, it copies the image's tree next to the old one and swaps them once the copy is complete. An image that is older than the recorded release refuses to start. tasks/01_manager_ops.yml then runs admin/cli/upgrade.php, which migrates the database.

Plugins installed through the web interface live only in the code volume and are not carried over to the next release. Declare a plugin under meta/addons/ so the image ships it.

Further Resources

Persona contract opt-outs

This role declares PERSONA_ADMINISTRATOR_BLOCKED in templates/playwright.env.j2. Moodle's site administrator is created by install_database.php in tasks/01_manager_ops.yml and realigned on every pass, both from the administrator user, so the native form and the identity provider carry one secret. In the SSO variant tasks/04_oidc.yml sets alternateloginurl so /login/index.php bounces to the OIDC plugin and rewrites every mdl_user row to auth='oidc', so no form accepts either secret; the SSO-off variants in meta/variants.yml have no OIDC chain to substitute. One env file is rendered per role, not per variant, so the flag has to hold for the worst case.

Biber keeps a runnable journey: files/playwright/test-ldap-variant.js drives the native LDAP-bind login and files/playwright/test-profile-readonly.js asserts the locked profile fields. The path back to the administrator persona is a deploy step that hands the site administrator a credential the persona env already carries.

Credits

Implemented by Kevin Veen-Birkenbach. Part of the Infinito.Nexus Project and maintained by Kevin Veen-Birkenbach. Licensed under the Infinito.Nexus Community License (Non-Commercial).