The moodle-docker project gives you a one-command Moodle dev stack — Apache, PHP, the database of your choice, mail catcher, the lot. What its README mentions only in passing is that you can run several Moodle instances on the same machine at the same time. Useful when you want to keep your current 4.5 happily ticking while you poke at a 5.0 upgrade in parallel. 🐳
Here are the bits that weren’t obvious from the docs, plus a small local.yml trick to fully isolate each instance’s database files.
Two stacks, one machine — what actually has to differ
The wrapper script bin/moodle-docker-compose is a thin layer over docker compose. Each docker compose invocation groups containers, networks, and volumes under a project name. Two stacks collide only when they share any of:
- Project name — same name means same containers; the second up will reconfigure the first.
- Host web port — only one process can bind localhost:8000 at a time.
- Code tree (the MOODLE_DOCKER_WWWROOT bind mount) — two Moodles writing the same source directory is a bad time.
Database volumes and the moodledata directory get scoped per project name automatically, so just changing the project name handles those for free.
The recipe, then, is: different project name + different web port + different code checkout. Express it as a per-instance env file you source before running the wrapper.
1 2 3 4 5 | # md-docker/moodle-env.sh — for Moodle 4.5 export COMPOSE_PROJECT_NAME=moodle45 export MOODLE_DOCKER_WWWROOT=/Users/me/dev/lms/moodle45/html export MOODLE_DOCKER_WEB_PORT=8000 export MOODLE_DOCKER_DB=mariadb |
1 2 3 4 5 6 | # md-docker/moodle-env50.sh — for Moodle 5.0 export COMPOSE_PROJECT_NAME=moodle50 export MOODLE_DOCKER_WWWROOT=/Users/me/dev/lms/moodle50/html export MOODLE_DOCKER_WEB_PORT=8001 export MOODLE_DOCKER_DB=mariadb export MOODLE_DOCKER_PHP_VERSION=8.3 |
Then drive each stack from its own shell:
1 2 3 4 5 6 7 | # Terminal A — 4.5 cd md-docker && source moodle-env.sh bin/moodle-docker-compose up -d # → http://localhost:8000 # Terminal B — 5.0 cd md-docker && source moodle-env50.sh bin/moodle-docker-compose up -d # → http://localhost:8001 |
Both containers’ Apache listens on the container’s port 80 internally — that’s fine, they live in separate network namespaces. The host port (8000 vs 8001) is the only number that has to be unique, because that’s the one that exists on your laptop. The README’s terse warning that “you need to have set up the environment variable COMPOSE_PROJECT_NAME to just refer to the instance you expect to” is exactly the catch: source the wrong env file and you’ll act on the wrong stack. Two terminal windows, one per stack, is the easiest habit.
Where does the database actually live?
Look at db.mariadb.yml in the moodle-docker tree — the db service has no volumes: section at all. That means MariaDB’s data directory at /var/lib/mysql inside the container is backed by an anonymous Docker volume: created automatically the first time the container starts, named with a random hash, scoped to the compose project name.
You can prove it to yourself:
1 2 3 | docker inspect $(bin/moodle-docker-compose ps -q db) \ --format '{{range .Mounts}}{{.Type}} {{.Name}} → {{.Destination}}{{println}}{{end}}' # volume a1b2c3... → /var/lib/mysql |
The implications are worth internalizing:
- docker compose down (no flag) — the anonymous volume survives, your data is fine.
- docker compose down -v — the volume is destroyed, your database is gone.
- Removing the container directly — same; the anonymous volume gets orphaned and eventually garbage-collected.
For day-to-day dev, that’s usually OK. For a development environment you’ve spent days seeding, it’s a sword hanging over your head. 🗡️
Pinning the database to a host path with local.yml
moodle-docker has a documented extension hook: if a file called local.yml exists in the md-docker directory, the wrapper merges it last into the compose invocation. It’s gitignored by default — meant exactly for per-machine overrides like this one.
1 2 3 4 5 | # md-docker/local.yml services: db: volumes: - "${MOODLE_DOCKER_DB_DATA}:/var/lib/mysql" |
Then in each env file, point at a per-instance host directory:
1 2 3 4 5 | # moodle-env.sh export MOODLE_DOCKER_DB_DATA=/Users/me/dev/lms/moodle45-db-data # moodle-env50.sh export MOODLE_DOCKER_DB_DATA=/Users/me/dev/lms/moodle50-db-data |
Now down -v will still try to remove the volume, but a bind mount to a host directory can’t be deleted by Docker — the files stay put on your laptop. The instances are also fully isolated at the filesystem level, which is handy if you want to tar one of them for a backup or move it between machines.
Two caveats worth knowing before you do this on macOS:
- Bind-mounted MariaDB data on macOS Docker Desktop is noticeably slower than a named or anonymous volume, because every fsync crosses the VirtioFS boundary into the Linux VM. Fine for dev, not great for benchmarking.
- If you’re migrating an existing anonymous-volume database into the bind mount, copy it out with the container stopped. docker cp container_id:/var/lib/mysql/. /path/on/host/ works, but a live copy will corrupt the snapshot. For backups, a logical mysqldump is portable and uid-independent — usually the right tool.
Moodle 5.0 needs a different web root
One more bit of local.yml business if you’re running a 5.x instance. Moodle 5 moved its front controller into a public/ subdirectory — public/index.php is now the entry point, not index.php at the repo root. The moodle-docker Apache image still defaults its DocumentRoot to /var/www/html, which is fine for 4.x but means 5.x will trip the install-time “Router not configured” check and 404 on clean URLs.
The fix is one extra environment entry in local.yml, overriding the Apache image’s APACHE_DOCUMENT_ROOT variable:
1 2 3 4 5 6 7 8 | # md-docker/local.yml services: db: volumes: - "${MOODLE_DOCKER_DB_DATA}:/var/lib/mysql" webserver: environment: APACHE_DOCUMENT_ROOT: /var/www/html/public |
The Apache config inside the container reads this env var at startup (the default site config has DocumentRoot ${APACHE_DOCUMENT_ROOT}), so a restart webserver is enough to pick up the change. Worth noting: this is a global override — if you only want it for the 5.x stack and not the 4.x one, you either need two different local.yml files swapped via the wrapper’s compose-file flag, or set the env var conditionally from the per-instance env-source script. For most dev setups, applying it to both is harmless: Moodle 4.x doesn’t have a public/ directory, so the 4.x stack just keeps using its own root and ignores the override.
While you’re in there, the same local.yml is a good place for any other per-machine compose-level overrides you accumulate — extra services, port bindings, additional bind mounts. It’s the one file moodle-docker explicitly leaves to you, gitignored by default, so you can shape it without worrying about upstream merge conflicts. 🐳
A few more Moodle 5 gotchas worth writing down.
If you’re following this recipe for a 5.x instance, three more things tripped me up that the moodle-docker README doesn’t mention:
Extra config.php entries for Moodle 5
A hand-rolled moodle/config.php (i.e. one you copied from an older Moodle install rather than generating from md-docker/config.docker-template.php) needs three small additions to keep 5.x happy:
1 2 3 4 5 6 | <?php // moodle/config.php — Moodle 5.x specific bits ini_set('zend.exception_ignore_args', '1'); $CFG->dbtype = 'mariadb'; $CFG->routerconfigured = true; |
The zend.exception_ignore_args setting suppresses argument values in PHP exception traces — Moodle 5 throws warnings on certain debug paths without it. $CFG->routerconfigured = true tells Moodle that you’ve handled the new front-controller routing (the public/ directory move described above), so it stops nagging you on every page load. And dbtype = ‘mariadb’ is just the right driver name if you’re on MariaDB — the older ‘mysqli’ value still works, but ‘mariadb’ picks up MariaDB-specific optimisations the Moodle 5 DAL (Data Access Layer) added.
Routing outbound mail through Mailpit
The md-docker stack always runs a Mailpit container (see service.mail.yml), and the Apache image proxies its UI at /_/mail/ on the webserver — so https://your-moodle.host/_/mail/ is where reset emails, course notifications, and the rest should land.
Except: if your config.php doesn’t tell Moodle to use Mailpit’s SMTP, none of that happens. With no $CFG->smtphosts, Moodle falls back to PHP’s mail(), which uses sendmail, which isn’t installed in the moodlehq/moodle-php-apache image. The mail is silently dropped, the password-reset form still cheerfully tells you “an email has been sent,” and you’ll spend a while wondering why Mailpit is empty.
Add these two lines:
1 2 3 | // Route outbound mail through the mailpit container (md-docker service.mail.yml). $CFG->smtphosts = 'mailpit:1025'; $CFG->noreplyaddress = 'noreply@example.com'; |
The hostname mailpit resolves on the compose network — no host port mapping needed. To sanity-check the wiring from the webserver container:
1 | bin/moodle-docker-compose exec webserver bash -c 'echo > /dev/tcp/mailpit/1025 && echo OK' |
Composer isn’t in the image — bootstrap it on first install
The moodlehq/moodle-php-apache image ships PHP and Apache but not Composer. Moodle’s vendor directory has to be populated before the installer will run, so on a fresh checkout you need a one-shot bootstrap inside the webserver container:
1 2 3 4 5 | cd ~/dev/lms/md-docker && source moodle-env50.sh bin/moodle-docker-compose exec webserver bash -c ' curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer cd /var/www/html && composer install --no-dev --classmap-authoritative ' |
The –no-dev skips test-only dependencies and –classmap-authoritative builds an optimised autoloader — both fine for a dev box that isn’t running PHPUnit. If you do want to run the test suite, drop those flags. After this finishes once, the vendor/ directory lives in your bind-mounted code tree, so subsequent stack restarts pick it up for free.
The pattern, condensed
- One env file per Moodle instance, each with a distinct COMPOSE_PROJECT_NAME and MOODLE_DOCKER_WEB_PORT.
- A single shared local.yml that wires MOODLE_DOCKER_DB_DATA as a bind mount, so each instance’s MariaDB data lives at a known host path — and, for 5.x, sets APACHE_DOCUMENT_ROOT to /var/www/html/public so the new front controller is reachable.
- Two terminals, two source ./env-files, and you can run 4.x and 5.x side-by-side without either one knowing the other exists. 🎉
None of this is exotic — it’s just three pieces of Docker Compose hygiene that the moodle-docker README hints at but doesn’t quite spell out. Once you have the env-file-per-instance habit, parallel Moodle versions stop feeling fragile.