From 5c536f5635c18155ab1022d5b740f1d333f44249 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Tue, 15 Sep 2026 17:10:40 +0200 Subject: [PATCH 1/3] refactor(compose): drop the idle cron container, restore the pre_start hook Two containers existed only to work around tooling. moodle-cron ran `sleep infinity` and did nothing else. Ofelia's job-exec needs a running container, so a service was added purely to be exec'd into - while the application container, which runs anyway, could host the job just as well. The labels move there and the service is gone. job-run would also avoid the idle container, but it needs every volume, network and environment variable repeated in labels: a second copy of the service definition, free to drift from the first. db-init went back to a one-shot service earlier because the compose on the CI runners rejected pre_start. That was the wrong repair - it changed the code to suit an outdated linter. netresearch/.github#420 makes the lint validate against current compose, so the hook returns: compose models init containers as pre_start, the step stays subordinate to the service, and it no longer shows up as a peer in `compose ps`. Neither decision was written down anywhere before; both now carry their reasoning in the file. Verified on a full stack: five containers instead of six, pre_start creates database and user, the Moodle install completes, ofelia registers "moodle.moodle-cron" and runs it to completion (failed: false), 19 scheduled tasks show a lastruntime, and the site answers 200. yamllint and markdownlint are clean. Assisted-by: claude-code:claude-opus-5 Agent-Session: https://claude.ai/code/session_01FRHeDxbgUnv868eNhVCCsr Agent-Host: 32116e Signed-off-by: Sebastian Mendel --- QUICKSTART.md | 2 +- README.md | 17 +++---- compose.yml | 130 +++++++++++++++++--------------------------------- 3 files changed, 55 insertions(+), 94 deletions(-) diff --git a/QUICKSTART.md b/QUICKSTART.md index b4b7911..34cee3c 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -89,7 +89,7 @@ To upgrade to a new Moodle version: 2. Rebuild the image and restart the stack. The sources ship inside the image, so a rebuild is required — without it the container refuses to start: ```bash - docker compose build moodle moodle-cron + docker compose build moodle docker compose up -d ``` diff --git a/README.md b/README.md index e545efe..d4c9cb8 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ nginx 1.31, MariaDB 12.3 (Docker Hardened Image), and Valkey 9. - **Modern Web Stack**: PHP-FPM + nginx architecture (no mod_php) - **HTTP/2 and HTTP/3 (QUIC)**: Modern protocol support out of the box - **Brotli Compression**: Better compression than gzip for modern browsers -- **Dedicated Cron Container**: Isolated cron execution via Ofelia scheduler +- **Scheduled Cron**: Ofelia runs Moodle cron in the application container + on a one-minute schedule - **Redis Sessions**: Valkey-backed session storage for scalability ## Architecture @@ -50,8 +51,8 @@ nginx 1.31, MariaDB 12.3 (Docker Hardened Image), and Valkey 9. │ └─────────────┘ └─────────────┘ │ │ │ │ ┌─────────────┐ ┌─────────────┐ │ - │ │ Moodle Cron │◄────────│ Ofelia │ │ - │ │ (PHP-FPM) │ exec │ Scheduler │ │ + │ │ Moodle App │◄────────│ Ofelia │ │ + │ │ (cron job) │ exec │ Scheduler │ │ │ └─────────────┘ └─────────────┘ │ │ │ │ ┌─────────────┐ (optional, dev profile) │ @@ -218,7 +219,7 @@ docker compose exec moodle php /var/www/html/admin/cli/maintenance.php --enable docker compose exec database mysqldump -uroot -p"$DB_ROOT_PASSWORD" moodle | gzip > backup-$(date +%Y%m%d).sql.gz # Rebuild the image with the new version, then restart -docker compose build moodle moodle-cron +docker compose build moodle docker compose up -d moodle # Watch the upgrade @@ -362,8 +363,8 @@ docker compose run --rm --no-deps --entrypoint cat moodle /var/www/html/.moodle- # On a mismatch, set MOODLE_VERSION in .env, then rebuild and restart. # Compose passes that value as the build argument, so do not pass it again # here - a divergence between the two is what caused the mismatch. -docker compose build moodle moodle-cron -docker compose up -d moodle moodle-cron +docker compose build moodle +docker compose up -d moodle ``` ### PHP-FPM Health Check Fails @@ -404,10 +405,10 @@ docker compose exec moodle chmod -R 0775 /var/moodledata docker compose logs ofelia # Check cron container -docker compose logs moodle-cron +docker compose logs ofelia # Run cron manually -docker compose exec moodle-cron php /var/www/html/admin/cli/cron.php +docker compose exec -u www-data moodle php /var/www/html/admin/cli/cron.php ``` ## Security Notes diff --git a/compose.yml b/compose.yml index 739c250..1ee2179 100644 --- a/compose.yml +++ b/compose.yml @@ -68,10 +68,52 @@ services: - frontend # Outbound access for Moodle itself (plugin updates, external services) - backend depends_on: - db-init: - condition: service_completed_successfully + database: + condition: service_healthy valkey: condition: service_healthy + # Init container: the hardened database image acts on MARIADB_ROOT_PASSWORD + # only and creates neither database nor user, and it offers no + # /docker-entrypoint-initdb.d. This runs once before the service starts, + # in the database image because the PHP image carries no client. + # Idempotent, so repeated starts are harmless. + pre_start: + - image: dhi.io/mariadb:12.3 + environment: + - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD is required} + - DB_NAME=${DB_NAME:-moodle} + - DB_USER=${DB_USER:-moodle} + - DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required} + command: + - /bin/sh + - -c + - | + set -eu + # Identifiers cannot be parameterised, so reject anything that would + # break out of the quoting instead of interpolating it blindly. + case "$$DB_NAME$$DB_USER" in + *[!A-Za-z0-9_-]*) echo "DB_NAME and DB_USER may only contain A-Z a-z 0-9 _ -" >&2; exit 1 ;; + esac + # Single quotes are the only metacharacter inside a SQL string literal. + esc_pw=$$(printf '%s' "$$DB_PASSWORD" | sed "s/'/''/g") + mariadb -h database -u root -p"$$MARIADB_ROOT_PASSWORD" \ + -e "CREATE DATABASE IF NOT EXISTS \`$$DB_NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + CREATE USER IF NOT EXISTS '$$DB_USER'@'%' IDENTIFIED BY '$$esc_pw'; + ALTER USER '$$DB_USER'@'%' IDENTIFIED BY '$$esc_pw'; + GRANT ALL PRIVILEGES ON \`$$DB_NAME\`.* TO '$$DB_USER'@'%'; + FLUSH PRIVILEGES;" + echo "database and user ready" + # Cron runs in this container rather than a dedicated one. Ofelia's + # job-exec needs a running container, and this one runs anyway; a separate + # service would have to sit in `sleep infinity` doing nothing. job-run + # would avoid the idle container too, but it needs every volume, network + # and environment variable repeated in labels - a second copy of the + # service definition that drifts. + labels: + ofelia.enabled: "true" + ofelia.job-exec.moodle-cron.schedule: "@every 1m" + ofelia.job-exec.moodle-cron.command: "php /var/www/html/admin/cli/cron.php" + ofelia.job-exec.moodle-cron.user: "www-data" healthcheck: test: ["CMD", "php-fpm-healthcheck"] interval: 30s @@ -79,44 +121,6 @@ services: retries: 3 start_period: 120s # Allow time for the first copy into the code volume - # ========================================================================== - # Moodle Cron - Dedicated Container for Scheduled Tasks - # ========================================================================== - moodle-cron: - image: netresearch/moodle:${MOODLE_VERSION:-5.2.3} - build: - context: ./docker/moodle - dockerfile: Dockerfile - args: - MOODLE_VERSION: ${MOODLE_VERSION:-5.2.3} - container_name: ${COMPOSE_PROJECT_NAME:-moodle}_cron - restart: unless-stopped - command: ["sleep", "infinity"] - volumes: - - moodle_code:/var/www/html:ro - - moodledata:/var/moodledata - environment: - - MOODLE_SKIP_BOOTSTRAP=true - - DB_TYPE=${DB_TYPE:-mariadb} - - DB_HOST=${DB_HOST:-database} - - DB_NAME=${DB_NAME:-moodle} - - DB_USER=${DB_USER:-moodle} - - DB_PASSWORD=${DB_PASSWORD} - - DB_PREFIX=${DB_PREFIX:-mdl_} - - VALKEY_HOST=${VALKEY_HOST:-valkey} - - VALKEY_PORT=${VALKEY_PORT:-6379} - - VALKEY_PASSWORD=${VALKEY_PASSWORD} - networks: - - backend - depends_on: - moodle: - condition: service_healthy - labels: - ofelia.enabled: "true" - ofelia.job-exec.moodle-cron.schedule: "@every 1m" - ofelia.job-exec.moodle-cron.command: "php /var/www/html/admin/cli/cron.php" - ofelia.job-exec.moodle-cron.user: "www-data" - # ========================================================================== # MariaDB 12.3 - Database (Docker Hardened Image) # @@ -143,50 +147,6 @@ services: retries: 5 start_period: 30s - # ========================================================================== - # Database bootstrap - creates the Moodle database and user - # - # The hardened image acts on MARIADB_ROOT_PASSWORD only: it creates neither - # database nor user, and offers no /docker-entrypoint-initdb.d. This runs - # once and exits. Idempotent, so repeated starts are harmless. - # - # Compose models init containers as pre_start hooks, which would be the - # tidier form, but the compose version on the CI runners (v2.38) rejects - # pre_start outright. A one-shot service works on every version. - # ========================================================================== - db-init: - image: dhi.io/mariadb:12.3 - container_name: ${COMPOSE_PROJECT_NAME:-moodle}_db_init - restart: "no" - entrypoint: ["/bin/sh", "-c"] - command: - - | - set -eu - # Identifiers cannot be parameterised, so reject anything that would - # break out of the quoting instead of interpolating it blindly. - case "$$DB_NAME$$DB_USER" in - *[!A-Za-z0-9_-]*) echo "DB_NAME and DB_USER may only contain A-Z a-z 0-9 _ -" >&2; exit 1 ;; - esac - # Single quotes are the only metacharacter inside a SQL string literal. - esc_pw=$$(printf '%s' "$$DB_PASSWORD" | sed "s/'/''/g") - mariadb -h database -u root -p"$$MARIADB_ROOT_PASSWORD" \ - -e "CREATE DATABASE IF NOT EXISTS \`$$DB_NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - CREATE USER IF NOT EXISTS '$$DB_USER'@'%' IDENTIFIED BY '$$esc_pw'; - ALTER USER '$$DB_USER'@'%' IDENTIFIED BY '$$esc_pw'; - GRANT ALL PRIVILEGES ON \`$$DB_NAME\`.* TO '$$DB_USER'@'%'; - FLUSH PRIVILEGES;" - echo "database and user ready" - environment: - - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD is required} - - DB_NAME=${DB_NAME:-moodle} - - DB_USER=${DB_USER:-moodle} - - DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required} - networks: - - backend - depends_on: - database: - condition: service_healthy - # ========================================================================== # Valkey 9 - Redis-Compatible Cache & Sessions # ========================================================================== @@ -223,7 +183,7 @@ services: networks: - backend depends_on: - - moodle-cron + - moodle # ========================================================================== # Mailpit - Development Mail Catcher (Optional) From bb352040773817d0bb60358b938b5d774919570f Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Tue, 15 Sep 2026 17:13:30 +0200 Subject: [PATCH 2/3] ci: validate and run against current compose, not the runner's Both compose-using jobs inherited whatever version the runner image ships. That version rejects `pre_start`, so the syntax check failed on a file that is valid, and the stack test would have failed to start it at all. docker/setup-compose-action, SHA-pinned, installs the current release in both jobs. The org-wide lint got the same treatment in netresearch/.github#420. Assisted-by: claude-code:claude-opus-5 Agent-Session: https://claude.ai/code/session_01FRHeDxbgUnv868eNhVCCsr Agent-Host: 32116e Signed-off-by: Sebastian Mendel --- .github/workflows/docker-build.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 5dd566d..1e0772f 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -26,8 +26,15 @@ jobs: sed -i 's/CHANGE_ME_SECURE_ROOT_PASSWORD/test_root_456/g' .env sed -i 's/CHANGE_ME_SECURE_VALKEY_PASSWORD/test_valkey_789/g' .env + # The runner image ships an older compose than deployments run, and it + # rejects keys current compose accepts (pre_start among them). Validate + # against current compose, not against the runner's. + - name: Set up Docker Compose + uses: docker/setup-compose-action@4eb059ff7f16592f9c84d5ca339c53cb7c5064e2 # v2.3.0 + - name: Validate docker-compose.yml syntax run: | + docker compose version docker compose config > /dev/null echo "Docker Compose syntax is valid" @@ -291,8 +298,14 @@ jobs: -out docker/nginx/ssl/cert.pem \ -subj "/CN=localhost/O=CI Test/C=US" + # Same reason as in validate-compose: compose.yml uses keys the runner's + # preinstalled version does not know. + - name: Set up Docker Compose + uses: docker/setup-compose-action@4eb059ff7f16592f9c84d5ca339c53cb7c5064e2 # v2.3.0 + - name: Build and start services run: | + docker compose version docker compose build --no-cache docker compose up -d echo "Services started" From 28108f01bbe9315045064c0b4707c67739298beb Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Tue, 15 Sep 2026 17:15:16 +0200 Subject: [PATCH 3/3] ci: ask setup-compose for a version, it installs nothing without one The previous commit added the action but left out the input, and the job log shows why that was not enough: the step ran, printed docker info and installed nothing, so `docker compose version` still reported the runner's v2.38.2 and the validation failed on pre_start exactly as before. Assisted-by: claude-code:claude-opus-5 Agent-Session: https://claude.ai/code/session_01FRHeDxbgUnv868eNhVCCsr Agent-Host: 32116e Signed-off-by: Sebastian Mendel --- .github/workflows/docker-build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 1e0772f..f3d3a98 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -31,6 +31,8 @@ jobs: # against current compose, not against the runner's. - name: Set up Docker Compose uses: docker/setup-compose-action@4eb059ff7f16592f9c84d5ca339c53cb7c5064e2 # v2.3.0 + with: + version: latest - name: Validate docker-compose.yml syntax run: | @@ -302,6 +304,8 @@ jobs: # preinstalled version does not know. - name: Set up Docker Compose uses: docker/setup-compose-action@4eb059ff7f16592f9c84d5ca339c53cb7c5064e2 # v2.3.0 + with: + version: latest - name: Build and start services run: |