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 21 22 23 | # 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" # Run cron every minute (Moodle's recommended cadence). Inner cron.php # polls for 30 seconds (cron_keepalive in the DB), then bash sleeps 60s # before relaunching. Total cycle ~91s — within Moodle's 120s threshold. command: ["bash", "-c", "while true; do php /var/www/html/admin/cli/cron.php; sleep 60; 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.
- while true; do php cron.php; sleep 60; done is the heart of it. Moodle recommends cron every minute, and the sleep 60 gives us exactly that. The reason for an explicit sleep rather than a tighter loop is twofold. First, Moodle’s interactive backup/restore wizards create database controllers in a transient “awaiting” state and then queue an async task for cron to finish — if cron polls aggressively, it picks up the controller before the user has clicked through the wizard’s confirmation steps, sees the wrong state, and marks the job failed (the cron log shows “Bad backup controller status, is: 800 should be 700, marking job as failed”). Production sites running cron every minute give the wizard plenty of buffer; a stack that polls every second consistently races it. Second, log volume — without a sleep, you get per-second “Cron run completed correctly” lines that make tailing the container useless for actual debugging.
- 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.
Quiet the inner task poller
Even with sleep 60 between invocations, each cron.php run has its own internal poll that loops for $CFG->cron_keepalive seconds before exiting. On a fresh moodle-docker install this defaults to 180, so every invocation spends three minutes printing per-second status lines like this:
1 2 3 4 | Ran 0 adhoc tasks found at Thu, 28 May 2026 16:53:03 +0100 Cron run completed correctly Cron completed at 16:53:03 in 0.010706 seconds. Memory used: 52.0 MB. Continuing to check for tasks for 106 more seconds. Ran 0 adhoc tasks found at Thu, 28 May 2026 16:53:04 +0100 |
It’s a database config value, not a CLI flag — set it once from inside the webserver container and it persists across restarts:
1 2 3 4 5 6 | bin/moodle-docker-compose exec webserver php -r ' define("CLI_SCRIPT", true); require "/var/www/html/config.php"; set_config("cron_keepalive", 30); echo "cron_keepalive now: " . get_config("core", "cron_keepalive") . PHP_EOL; ' |
The value is a tradeoff against task latency. With cron_keepalive=30 + sleep 60, a freshly queued adhoc task is picked up within ~30 seconds of being created (during the inner poll window of any running invocation) and at most 60 seconds even if it lands during a sleep gap. With cron_keepalive=0, each cron.php exits immediately after one pass — no internal polling, completely quiet between sleeps, but tasks always wait the full sleep 60 at worst. I went with 30 as the default.
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 (45 sec ago) lastcroninterval: 91 seconds |
lastcronstart updated within the last minute or so, lastcroninterval in the 80-100s range (matching the cycle of cron_keepalive 30s + sleep 60s). 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:
- “…has never been run and should run every 1 min.” The starting state, before the cron container has touched the database.
- “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.
- (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.