Sign inSign up

pikapods/docker-moodle

By pikapods

Updated 6 days ago

Image
0

1.7K

pikapods/docker-moodle repository overview

docker-moodle

Moodle container image, built on serversideup/php.

This image powers Moodle on PikaPods and is maintained by the PikaPods team. It's published here for our users' reference and the benefit of the wider community.

Published to both ghcr.io/pikapods/docker-moodle:<moodle-version> and pikapods/docker-moodle:<moodle-version> (Docker Hub).

Source: https://github.com/pikapods/docker-moodle

Why this image

Moodle has no high-quality community Docker image. The dominant choice — Bitnami's image — persists the entire Moodle codebase under /bitnami/moodle, which makes image-tag upgrades unreliable (volume code shadows new code) and produces large layers.

This image bakes the Moodle source into the image at a pinned tag and persists only operator state under /data: the generated config.php and the Moodle dataroot. Image upgrade = pull a new tag; nothing in the volume shadows the new code.

Quick start

The bundled compose.yaml brings up Moodle plus a MariaDB sidecar with zero external dependencies — the fastest way to try the image:

git clone https://github.com/pikapods/docker-moodle.git
cd docker-moodle
docker compose up -d
# First-boot install runs admin/cli/install.php and takes 1–3 minutes.
docker compose logs -f moodle | grep -E 'bootstrap|install\.php'
# Once the healthcheck flips to "healthy", browse:
curl -I http://localhost:8080/login/index.php   # → HTTP/1.1 200 OK

Default credentials are admin / changeme — change them before any real deployment.

Against an existing database:

docker run -d --name moodle \
  -v moodle-data:/data \
  -e MOODLE_URL="https://learn.example.com" \
  -e DB_TYPE=mariadb \
  -e DB_HOST=db.internal \
  -e DB_NAME=moodle \
  -e DB_USER=moodle \
  -e DB_PASS=... \
  -e ADMIN_USER=admin \
  -e ADMIN_PASS=changeme \
  -e [email protected] \
  -e SITE_FULLNAME="My Moodle" \
  -e SITE_SHORTNAME=moodle \
  -p 8080:8080 \
  ghcr.io/pikapods/docker-moodle:latest
Running on podman

The compose file and docker run examples work as-is under podman compose / podman run. Three podman-specific notes:

  • Build: podman build --format docker …. Podman defaults to OCI manifests, which silently drop the HEALTHCHECK instruction; docker format embeds it.
  • Rootless permissions: rootless podman remaps UIDs, so the container's www-data (UID 82) isn't host UID 82. For bind mounts, add --userns=keep-id:uid=82,gid=82. Rootful podman behaves like docker. Full decision matrix in User & permissions.
  • Healthcheck inspection: podman healthcheck run <container> runs the check on demand. Docker runs it automatically; inspect with docker inspect --format '{{.State.Health.Status}}' <container>.

Environment variables

Core
VarRequiredPurpose
MOODLE_URLyesPublic URL (no trailing slash). Patched into $CFG->wwwroot every boot.
DB_TYPEnomariadb (default), mysql, or pgsql (aliases: postgres, postgresql).
DB_HOSTyesDB hostname.
DB_PORTnoDB port. Defaults to 3306 (mariadb/mysql) or 5432 (pgsql).
DB_NAMEyesDB name.
DB_USERyesDB user.
DB_PASSnoDB password. May be empty for passwordless local dev DBs.
DB_PREFIXnoTable prefix. Defaults to mdl_. Honored on first boot only.
Install seed (first boot only)
VarRequired whenPurpose
ADMIN_USERnoAdmin username. Defaults to admin.
ADMIN_PASSfirst bootAdmin password.
ADMIN_EMAILfirst bootAdmin email.
SITE_FULLNAMEnoSite full name. Defaults to Moodle.
SITE_SHORTNAMEnoSite short name. Defaults to moodle.
SUPPORT_EMAILnoDefaults to ADMIN_EMAIL.
MOODLE_LANGnoInstall language. Defaults to en.

Once config.php exists in the volume, install-seed vars are ignored. Safe to leave them set on subsequent boots.

Cron services
VarDefaultPurpose
ENABLE_MOODLE_CRONTRUEDisable with any of FALSE/false/0/no/off. Stops the per-minute cron.php loop.
ENABLE_MOODLE_ADHOCTRUEDisable with any of FALSE/false/0/no/off. Stops the adhoc task worker.

Both run as s6 long-runs inside the container. Moodle is broken without cron (notifications, queues, gradebook regrade, scheduled tasks), so the only sensible default is TRUE.

Moodle config.php passthrough

Any env var named MOODLE_CFG_<KEY> is patched into a managed block inside /data/config/config.php on every boot. The key is lowercased and assigned to $CFG-><key>. Example:

MOODLE_CFG_SMTPHOSTS=smtp.mailgun.org:587
MOODLE_CFG_NOEMAILEVER=true
MOODLE_CFG_MAXBYTES=104857600

becomes

$CFG->smtphosts = 'smtp.mailgun.org:587';
$CFG->noemailever = true;
$CFG->maxbytes = 104857600;

inside the // BEGIN moodle-image-managed// END moodle-image-managed block, which sits immediately above the require_once('lib/setup.php') line so its assignments win over any install-written defaults.

Type coercion. Values are emitted as:

Value patternPHP literal
true / TRUE / Truetrue
false / FALSE / Falsefalse
NULL / Nullnull
^-?[0-9]+$int
anything elsesingle-quoted string

Sentinel deletion. Setting MOODLE_CFG_<KEY>=unset, =null (lowercase), or empty deletes the key on the next boot. To set a PHP null literal, use NULL or Null.

Key validation. Keys are lowercased and must match ^[a-z][a-z0-9_]*$. Invalid keys (dashes, regex metachars, names starting with a digit) are logged and skipped.

Mounts

PathPurpose
/dataPersistent volume. Contains config/config.php and moodledata/.
/var/www/htmlMoodle source. Baked at build time — do not bind-mount.

/var/www/html/config.php is a symlink into /data/config/config.php so the single canonical config file lives on the volume. Moodle's dataroot (uploads, sessions, cache, locks) is at /data/moodledata/ — outside the docroot and therefore not web-accessible.

User & permissions

Both nginx and php-fpm run as www-data (UID 82 / GID 82 — Alpine's default, inherited from serversideup/php:*-alpine). How those writes surface on the host depends on your runtime; pick the row that matches:

SetupWhat to doHost-side ownership of /data writes
Named volume (docker or podman)Nothing — daemon manages ownership. Default in compose.yaml.Inside daemon-managed volume; not user-visible.
Bind mount, rootful docker/podmanchown -R 82:82 <host-dir> before first boot.82:82.
Bind mount, rootless podmanAdd --userns=keep-id:uid=82,gid=82 to podman run.Invoking host user's UID/GID.
Custom-UID rebuilddocker build --build-arg WWW_DATA_UID=$(id -u) --build-arg WWW_DATA_GID=$(id -g) -t moodle:local .The UID baked at build time.

The bootstrap runs a preflight writability check on /data and refuses to start with a readable error if ownership is wrong, rather than failing cryptically deep in mkdir.

Why not a runtime PUID/PGID env var? Upstream serversideup/php v3 deliberately removed root from the boot path, and runtime UID remap requires reintroducing it. The supported lever is the build-time WWW_DATA_UID/WWW_DATA_GID rebuild above.

Ports

PortPurpose
8080HTTP (serversideup's unprivileged default).

Behind a reverse proxy this is invisible to end users.

config.php ownership model

/data/config/config.php is user state, not a regenerated artifact — generated once by admin/cli/install.php on first boot and preserved on every subsequent boot.

Each boot, the image strips any prior // BEGIN moodle-image-managed// END moodle-image-managed block from config.php and re-inserts a fresh one immediately before the require_once(.../lib/setup.php) line. The block contains:

  1. Always-patched ops keys from env, in order: wwwroot, dbtype, dbhost, dbname, dbuser, dbpass, dboptions['dbport']. Env wins — change any of these and restart, and the next boot's managed block reflects the new value.
  2. Any MOODLE_CFG_* env vars, type-coerced per the table above.

Everything else in config.php is preserved untouched. Hand-edits via docker exec, custom settings you've appended below the managed block — all survive boots. Because the managed block sits above require_once but below install.php's own assignments, its keys overwrite the install-time values; assignments you add below the block (your hand-edits) still win, since later PHP assignments overwrite earlier ones.

DB_PREFIX is honored on first boot only — changing the table prefix on an existing install would orphan all data. Set it correctly the first time.

Major-version upgrades

Across minor Moodle versions this image upgrades cleanly: pull a new tag and restart. Moodle handles the version-mismatch dialog automatically on first admin login.

Across major versions (v5.xv6.x), run the upgrade CLI once manually:

docker compose pull moodle && docker compose up -d moodle
docker compose exec moodle php /var/www/html/admin/cli/upgrade.php \
    --non-interactive

This image does not run upgrade.php automatically — silent schema upgrades across majors are the wrong default for production data.

Deliberate breaks vs. bitnami/moodle

BreakRationale
Default port 8080 (was 8080 HTTP / 8443 HTTPS)Same.
App lives at /var/www/html (was /bitnami/moodle)Source baked into image. /bitnami/moodle shadows new code on upgrade.
Only config.php + moodledata persisted, not the codebaseImage tag upgrades are reliable; no in-volume drift.
Env vars renamed (MOODLE_* → simpler MOODLE_URL / DB_*)Not drop-in compatible. Document migration if you need it.
ENABLE_MOODLE_CRON defaults TRUEMoodle is broken without it.
No auto-upgrade.php across majorsSilent schema upgrades are wrong for production data.

Building locally

docker build \
  --build-arg MOODLE_VERSION=v5.2.0 \
  --build-arg PHP_VERSION=8.3 \
  -t moodle:test .

On podman, add --format docker — see the podman notes in Quick start for why.

Pinning inputs

The published image uses floating tags (serversideup/php:8.3-… base, Moodle git tag) so the daily CI rebuild picks up upstream security fixes.

For a more reproducible build:

  • Moodle version. MOODLE_VERSION is passed to git clone --branch, so it must be a tag or branch name — v5.2.0, MOODLE_502_STABLE, etc. Arbitrary commit SHAs are not supported by --branch; if you need commit-level pinning, fork the Dockerfile and replace the clone step with a git fetch && git checkout <sha>.
  • Base image. Override serversideup/php in your own derived Dockerfile with a digest pin: FROM serversideup/php:8.3-fpm-nginx-alpine@sha256:….
  • Compose. The bundled compose.yaml defaults to :latest for the quick-start path; in production, pin to a specific Moodle version tag (e.g. ghcr.io/pikapods/docker-moodle:v5.2.0).

License

The Moodle source is GPL-3.0; this image inherits that license.

Tag summary

Content type

Image

Digest

sha256:98ae10a32

Size

250.4 MB

Last updated

6 days ago

docker pull pikapods/docker-moodle