Adding a Moodle cron sidecar to moodle-docker via local.yml

The moodle-docker stack is designed primarily for automated testing — Behat, PHPUnit, and so on — which is why it doesn’t ship a cron container. A background scheduler would interfere with deterministic test runs, so the README’s only nod to it is “run cron.php manually if you want.” 🕒

That’s fine for testing. It’s less fine if you’re using the stack for day-to-day development, because the Moodle admin notifications page will pester you with the warning “The admin/cli/cron.php script has never been run and should run every 1 min” until cron actually runs on a sensible interval. This post is the working pattern I landed on: a tiny cron sidecar added through moodle-docker’s official extension point.

Use local.yml, not a fork

moodle-docker has a documented extension hook: any local.yml file in the md-docker directory is merged last into the compose invocation. It’s gitignored by default — purpose-built for per-machine overrides like this one — so additions here don’t conflict with future git pull on the upstream repo.

Here’s the full file. If you already have a local.yml (for a bind-mounted database, for the Moodle 5 APACHE_DOCUMENT_ROOT override, etc.), just add the cron service block to what you’ve got:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# md-docker/local.yml
services
:
  db
:
    volumes
:
     - "${MOODLE_DOCKER_DB_DATA}:/var/lib/mysql"
  webserver
:
    environment
:
      APACHE_DOCUMENT_ROOT
: /var/www/html/public
  cron
:
    image
: "moodlehq/moodle-php-apache:${MOODLE_DOCKER_PHP_VERSION}"
    depends_on
:
     - db
    volumes
:
     - "${MOODLE_DOCKER_WWWROOT}:/var/www/html"
    environment
:
      MOODLE_DOCKER_DBNAME
: moodle
      MOODLE_DOCKER_DBUSER
: moodle
      MOODLE_DOCKER_DBPASS
: "m@0dl3ing"
    command
: ["bash", "-c", "while true; do php /var/www/html/admin/cli/cron.php --keep-alive=50; done"]
    restart
: unless-stopped

A few things worth noting about that block:

  • Same image as the webserver. moodlehq/moodle-php-apache:${MOODLE_DOCKER_PHP_VERSION} ensures the cron process runs on the same PHP version your web requests do — no surprises around extension availability or version-specific bugs.
  • Same code mount. Bind-mounting ${MOODLE_DOCKER_WWWROOT} means cron sees exactly the code the webserver sees, including your in-progress edits.
  • DB env values match moodle-docker’s defaults. The hardcoded moodle / moodle / m@0dl3ing triplet matches the base.yml defaults so the cron container talks to the same database the webserver does. If you’ve customised these in your own setup, mirror those changes here.
  • –keep-alive=50 is the important flag. Moodle 5’s cron.php supports an internal task-polling loop that runs for N seconds and then exits. With –keep-alive=50 inside an outer while true loop (no sleep), each cron.php invocation lasts ~50 seconds and restarts immediately. The lastcronstart timestamp Moodle records gets updated roughly every 55 seconds, which is well inside the default “expected every 60s, warn after 120s” threshold.
  • restart: unless-stopped for safety — if cron.php exits with an error and the outer shell loop dies for some reason, Docker brings the container back up.

Pin the PHP version explicitly

The wrapper script bin/moodle-docker-compose provides a default of 8.3 for MOODLE_DOCKER_PHP_VERSION via a shell parameter expansion, but it’s worth setting the variable explicitly in your environment file so the value is documented in one obvious place and doesn’t drift if the wrapper’s default ever changes.

In md-docker/moodle-env50.sh (or whichever per-instance env file you source):

1
export MOODLE_DOCKER_PHP_VERSION=8.3

If you’re running multiple stacks (4.5, 5.0, etc.), add the same line to each env file, possibly with different version pins per stack.

Bring it up

1
2
3
cd md-docker
source moodle-env50.sh
bin/moodle-docker-compose up -d cron

You should see Container [project]-cron-1 Started in the output. From this point on, docker compose up / down brings cron up and tears it down alongside the rest of the stack — no stray processes left running when you stop your dev environment.

Validation

Moodle records the cron’s last start time in the tool_task plugin’s config table. You can read it directly from inside the cron container:

1
2
3
4
5
6
7
8
bin/moodle-docker-compose exec cron php -r '
  define("CLI_SCRIPT", true);
  require "/var/www/html/config.php";
  $start = get_config("tool_task", "lastcronstart");
  $interval = get_config("tool_task", "lastcroninterval");
  echo "lastcronstart: " . date("Y-m-d H:i:s", $start) . " (" . (time() - $start) . " sec ago)" . PHP_EOL;
  echo "lastcroninterval: $interval seconds" . PHP_EOL;
'

After cron has been running for a couple of minutes, you want to see something like:

1
2
lastcronstart: 2026-05-28 14:34:43 (10 sec ago)
lastcroninterval: 51 seconds

lastcronstart updated within the last minute, lastcroninterval in the 50-60s range. That’s the healthy state.

How the admin warning unwinds

If you check the Moodle admin notifications page (Site administration → Notifications) while this is happening, the warning text changes through three states as cron settles in:

  1. “…has never been run and should run every 1 min.” The starting state, before the cron container has touched the database.
  2. “There was 4 mins between the last two runs of the cron maintenance script and it should run every 1 min.” The middle state, after a single cron cycle but before two consecutive cycles have happened inside the expected frequency. This usually clears in 1-2 minutes once the container has been running steadily.
  3. (no cron mention at all) The healthy state. The admin notifications page shows only unrelated warnings.

If you’re stuck at state 2 for more than a few minutes, purge Moodle caches once (bin/moodle-docker-compose exec webserver php /var/www/html/admin/cli/purge_caches.php) and reload the admin page. The status checks are themselves cached, so the page can lag the actual state by a cycle or two. 🐳

Stopping and starting

Day-to-day this is invisible. bin/moodle-docker-compose up -d brings everything up, including cron. bin/moodle-docker-compose down stops it. bin/moodle-docker-compose logs -f cron tails the output if you want to see scheduled tasks executing in real time. The sidecar plays by the same rules as every other service in the stack — no host-side launchd, no host crontab entry, no stray processes after a docker compose down.

This entry was posted in Moodle and tagged , , . Bookmark the permalink.

Comments are closed.