Moodle upgrades are straightforward in theory and full of edge cases in practice: a PHP version that needs bumping, a theme that doesn’t survive the transition, a third-party plugin that’s been abandoned, a collation mismatch that only shows up halfway through the upgrade. This guide walks through the full workflow for both major and minor upgrades using Git, with the checks and commands that actually catch problems before they hit production.

Minor vs. major upgrades

A minor upgrade (for example 4.5.3 to 4.5.4) is usually a security or maintenance patch. You can deploy it by overwriting the core codebase. A major upgrade (for example 4.1 to 4.5) typically also requires:

  • Updating parts of the LAMP/LEMP stack: PHP, database, sometimes the web server
  • Reviewing or rebuilding a customized theme
  • Re-validating third-party plugins against the new version

Most of this guide is about major upgrades. The minor upgrade section at the end covers what differs.

Read Moodle’s upgrade documentation first

Start with the official Upgrading page on docs.moodle.org. The core procedure comes down to:

  • Back up everything: database, moodledata directory, and the entire codebase (usually under public_html, htdocs, or a moodle/ subdirectory)
  • Replace the codebase with the new version
  • Copy the old config.php into the new tree
  • Reinstall the new versions of any third-party plugins

Everything else in this guide is about making that procedure survive contact with reality.

Check the server requirements

Verify that the server meets the new Moodle version’s minimum requirements. The things that tend to bite:

  • Database: MySQL or MariaDB version, plus row format. Upgrades crossing the 3.1 boundary need the Antelope-to-Barracuda conversion and the utf8mb4_unicode_ci collation.
  • PHP: version and all required extensions.
  • Web server: Moodle itself doesn’t care much, but PHP-FPM socket paths and proxy configuration change when you bump PHP versions.

If the stack doesn’t meet the requirements, do not update the stack yet. An OS or PHP bump on the running site will break the current Moodle before the new code is in place. Plan those changes so they happen during the upgrade maintenance window, with the site in maintenance mode and the new codebase ready to deploy.

Installing a separate PHP version with PHP-FPM on Ubuntu

Running two PHP versions side by side lets you upgrade Moodle without disturbing other sites on the same server. (See also this reference.)

Add Ondřej Surý’s Apache and PHP repositories:

sudo add-apt-repository ppa:ondrej/apache2
sudo apt-get update
sudo apt install software-properties-common libapache2-mod-fcgid
sudo add-apt-repository ppa:ondrej/php && sudo apt update

Install the target PHP version with the extensions Moodle needs:

sudo apt-get install -y php8.3-{bcmath,bz2,curl,gd,intl,mbstring,mysql,soap,xml,zip,common,fpm}

In the vhost configuration, tell Apache to hand PHP requests to this FPM pool. Place the FilesMatch directive before the Directory block:

<FilesMatch .php>
    SetHandler "proxy:unix:/var/run/php/php8.3-fpm.sock|fcgi://localhost/"
</FilesMatch>

<Directory /home/yoursite/public_html>
    ...
</Directory>

Enable the proxy modules if they aren’t already, and reload Apache:

sudo a2enmod proxy_fcgi proxy
sudo systemctl reload apache2

Review /etc/php/8.3/fpm/php.ini and carry over the tuning from your old php.ini — at minimum upload_max_filesize, post_max_size, max_execution_time, memory_limit, and max_input_vars. Restart FPM after any change:

sudo systemctl restart php8.3-fpm

PHP-FPM also needs its pool settings tuned for your traffic profile (pm, pm.max_children, and friends), which is out of scope for this guide.

Update the cron job

If the PHP version has changed and you’re running multiple PHP versions on the server, update the Moodle cron command accordingly. For example, from:

/usr/bin/php8.1 /home/yoursite/public_html/admin/cli/cron.php >/dev/null

to:

/usr/bin/php8.3 /home/yoursite/public_html/admin/cli/cron.php >/dev/null
Update backup scripts

Any custom backup scripts that invoke PHP directly should be updated to the new PHP version. Also double-check any hard-coded paths: the location of config.php and the default data directory occasionally shift across major Moodle or Totara versions, and a backup script that silently misses them will fail exactly when you need it.

Take inventory of third-party plugins

Visit /admin/plugins.php on the running site and list every plugin marked Additional. These are the third-party plugins you will need to upgrade separately, usually from moodle.org/plugins/.

For very old installations, you may need to chain upgrades (for example 3.1 → 3.9 → 4.1 → 4.5) because Moodle only supports direct upgrades within a bounded range. For each intermediate version, check that the plugin set you depend on has a release covering that step.

The theme deserves special attention

Themes rarely survive a major upgrade unchanged, unless the theme is a stock Moodle theme with only configuration customizations (logo, colors, favicon). A heavily customized child theme almost always needs developer work to run on the new version. Budget for this before you start — discovering it on upgrade day is expensive.

Watch for non-Moodle content inside the Moodle directory

External files — images, PDFs, archived course exports, the occasional forgotten test page — sometimes end up inside the wwwroot even though they don’t belong there. Inventory them before the upgrade and restore them in the same relative paths afterwards, or take the opportunity to move them to a proper asset location outside the Moodle tree.

Copying third-party plugins between code bases

A quick script to copy directories present in a modified code base but missing from a clean upstream tree. Useful when you’re reconstituting the plugin set on top of a clean Moodle install:

#!/bin/bash

clean="/home/you/temp/moodle-clean"
modified="/home/you/temp/moodle-modified"
target="/home/you/php/example-site/public_html"

# Find directories in modified that are missing in clean, excluding hidden directories
find "$modified" -mindepth 1 -type d ! -path "*/.*" | while read dir; do
    relative_path="${dir#$modified/}"
    if [ ! -d "$clean/$relative_path" ]; then
        echo "Copying: $relative_path"
        rsync -av "$dir" "$target/${relative_path%/*}/"
    fi
done

Check for core hacks

Core hacks on modern Moodle are rare, but verify rather than assume. Diff the running code base against the exact official build for the same version:

diff -qr --exclude='.git' moodle-clean/ moodle-current/

Any file reported as different needs a decision: port the change forward, drop it, or replace it with a supported mechanism (a local plugin, a hook, a subtheme override). Unannotated core hacks are a major upgrade’s favorite way to go sideways.

Finding the exact Moodle build

To compare against the right upstream commit, pin your clone to the same version the running site is on. In a local clone of the official Moodle repository:

1. Switch to and update the stable branch

git checkout MOODLE_405_STABLE
git pull origin MOODLE_405_STABLE

This ensures your local branch contains every version bump and bugfix up to the latest commit.

2. Search for the version bump in version.php

Get the build date from $release in the running site’s version.php (e.g. 20250131), then:

git log -S "20250131" -- version.php

The -S flag looks for commits that add or remove that literal string in version.php. The top result is the commit where Moodle’s maintainers set $release = '4.1.15+ (Build: 20250131)';.

3. Check out that specific commit

git checkout 59a1f3e4a2b8c5d7e8f9a0123b4c5d6e7f8a9b0c

Or, if you want a named branch at that point:

git checkout -b moodle-4.1.15-build20250131 59a1f3e4a2b8c5d7e8f9a0123b4c5d6e7f8a9b0c

4. Verify

grep '$version|$release' version.php

You should see exactly:

$version  = 2022112815.06;
$release  = '4.1.15+ (Build: 20250131)';

Your working copy is now pinned to the exact same Moodle build the running site is supposedly on, and any diff output represents a real modification.

Staging upgrade for stakeholder sign-off

Before touching production, build a staging environment that mirrors the live one as closely as you can: same OS, same PHP version, same database engine, same Moodle version as the starting point. Give it its own domain — for example contoso-staging.example.com.

Create the vhost as you would for a fresh install, but instead of installing a new site, restore a copy of production and upgrade that.

Setting expectations with stakeholders

Make one thing unambiguous to anyone you invite to test: changes made on the staging site will not transfer to the live upgrade. Any content, configuration, or test data entered there is scratch.

Focus testing on the customizations — the custom theme, custom plugins, integrations with external systems (SSO, HR sync, reporting tools). Moodle core and widely-used community plugins have already been tested by thousands of sites on the same version. Your custom stack has not.

Getting the upgraded code into the repository

The cleanest way to deploy the new Moodle version is through a dedicated upgrade branch, based on the official upstream. In your local clone of the site’s repository, add the official Moodle repo as upstream:

git remote add upstream https://github.com/moodle/moodle.git

Fetch the target stable branch (confirm the exact branch name with git remote show upstream):

git fetch upstream MOODLE_405_STABLE

Create a local tracking branch:

git checkout -b moodle405 upstream/MOODLE_405_STABLE

Optionally push to your own origin for safekeeping:

git push -u origin moodle405

Create the upgrade branch based on it:

git checkout -b upgrade moodle405

Then drop in the new versions of your third-party plugins, commit, and push:

git commit -a -m "Bring in Moodle 4.5 code and updated plugins"
git push --set-upstream origin upgrade

Upgrading the staging site

On the staging server:

  • Restore the production database
  • Restore the production moodledata directory
  • Check out the upgrade branch
  • Confirm all third-party plugins are in place at the new versions
  • Copy config-dist.php to config.php and adjust wwwroot, dataroot, database credentials, and any directory references
  • Check whether any language packs need updating for the new version

Then visit /admin/ in a browser. Look carefully at the Current release information page before clicking Continue:

  • Are all Server Checks and Other Checks green? HTTPS warnings are acceptable on a throwaway staging domain.
  • Any collation issues? Don’t just change the value in config.php — run the CLI converter:
php admin/cli/mysql_collation.php --collation=utf8mb4_unicode_ci

Once the checks are clean, run the actual upgrade from the command line rather than the web UI. The web upgrade will hit PHP or Apache timeouts on any reasonably sized database.

  • Confirm your CLI PHP matches the PHP-FPM version serving the site: php -v.
  • Start a screen (or tmux) session so the upgrade survives a broken SSH connection.
  • Run the upgrade:
php admin/cli/upgrade.php

If multiple PHP versions are installed, specify the right one explicitly, for example php8.3 admin/cli/upgrade.php.

Post-upgrade smoke tests

  • Can Moodle send email? Send a test from Site administration → Server → Email → Test outgoing mail configuration.
  • Is cron running cleanly? Run admin/cli/cron.php once manually and watch the output.
  • Spot-check the custom theme, custom plugins, and any SSO or external integrations.

Handing staging to stakeholders

Share the staging URL and credentials with the people who need to sign off. Use a secure channel for the credentials — a password manager share, an encrypted file, or another out-of-band mechanism — not plain email. Set a clear deadline for feedback so the upgrade doesn’t stall for weeks.

Performing the live upgrade

Once staging is approved, prepare the Git repository. Fast-forward master to the upgrade branch:

git checkout upgrade
git pull
git checkout master
git pull
git reset --hard upgrade
git push --force origin master

If your team uses main, substitute. The force-push is deliberate — a major upgrade is a hard cutover, and a fast-forward would leave the old pre-upgrade history tangled with the new code.

Then, on the live server:

  1. Confirm all third-party plugins are committed to the branch.
  2. Put Moodle in maintenance mode: Site administration → Server → Maintenance mode, or visit /admin/settings.php?section=maintenancemode.
  3. Take a complete backup of the moodledata directory, the database, and the entire code base. If the hosting environment supports it, a full system snapshot is faster and more reliable.
  4. If the OS or stack needs updating (for example, bumping PHP), do it now. Moodle is in maintenance mode, so the transient breakage is contained. Confirm that no other sites on the server depend on the old PHP version or database software.
  5. Preserve your php.ini tuning. Before cutting over, note upload_max_filesize, post_max_size, max_execution_time, memory_limit, and max_input_vars, and carry them forward into the new version’s php.ini.
  6. Replace the source code:
git checkout master
git fetch origin
git reset --hard origin/master
  1. Restore config.php from the backup of the old code base.
  2. Visit /admin/ to check for plugin issues before triggering the upgrade.
  3. Start a screen session and run the upgrade:
php admin/cli/upgrade.php
  1. Run the same smoke tests you ran on staging: email, cron, custom theme, integrations.
  2. If staging accumulated backend configuration you want on live (for example, customized theme settings), export it with Site administration → Development → Admin presets from staging, and import it on live via /admin/tool/admin_presets/index.php. Beware: if the preset was exported from a site with maintenance mode disabled, the import will disable maintenance mode on the target too.
  3. Take the site out of maintenance mode.
  4. Notify stakeholders that the upgrade is complete.

Minor upgrades

Minor upgrades typically land in response to a Moodle Security Alert — the email from securityalerts@moodle.org with subject “Moodle Security Alerts.” For any site with real users, these patches should be applied promptly. For sites with contractual SLAs, it’s often non-negotiable.

Doing it manually

The manual minor-upgrade flow mirrors the major one but is much shorter:

  1. Fetch upstream and merge the latest minor tag into your site branch:
git fetch upstream
git merge v4.5.4
  1. Push, then pull on the server.
  2. Enable maintenance mode.
  3. Run php admin/cli/upgrade.php --non-interactive.
  4. Disable maintenance mode.
  5. Smoke-test: email, cron, a logged-in course view.

Third-party plugins stay in place because this is a merge, not a tree replacement. As long as the plugins are committed to the site branch, the merge only touches the core files that changed upstream.

Automating it

Minor upgrades are a good candidate for automation: they arrive on a predictable cadence, follow a deterministic flow, and rarely need human judgement. Solin runs managed client sites through an internal pipeline that:

  • Polls upstream Moodle tags nightly and selects the latest eligible patch within the site’s major version
  • Merges the tag into the site branch and pushes
  • Triggers a deploy job via webhook — the job pulls, enables maintenance mode, runs the non-interactive upgrade, disables maintenance mode, and runs health checks
  • On failure, rolls back automatically from the pre-deploy backup and alerts the on-call engineer

If you build something similar in-house, the main pitfalls are:

  • Don’t force-rewrite site branches — that’s how custom plugins disappear.
  • Make the health check hit a URL that actually exercises the database, not just a cached front page.
  • Log enough to reconstruct what happened after an unattended rollback at 3am.

Troubleshooting

Forced HTTPS redirect on a staging domain

Symptom: a test PHP script runs fine on the staging vhost, but any request to Moodle itself is redirected to the server’s default site or forced to https:// even though there’s no certificate on the staging domain.

Cause: the running site has $CFG->overridetossl set. This was a common pattern on older Moodle installations that only wanted HTTPS on login pages. After an upgrade, and especially when restoring into a staging environment without a certificate, it produces unwanted forced redirects.

Fix: in lib/setuplib.php, inside initialise_fullme(), add unset_config('overridetossl'); at the top of the relevant block:

unset_config('overridetossl');

if (!empty($CFG->overridetossl {
    if (strpos($CFG->wwwroot, 'http://') === 0) {
        $CFG->wwwroot = str_replace('http:', 'https:', $CFG->wwwroot);
    } else {
        unset_config('overridetossl');
    }
}

This is a staging workaround. Don’t commit it to the branch that ships to production.

H5P content type downloads

After an upgrade, H5P content types may not appear until the “Download available H5P content types from h5p.org” scheduled task has had a chance to run. To force it, temporarily set that task’s schedule to every 1 minute under Site administration → Server → Scheduled tasks, let it run once, then reset it to the default schedule.

Need help?

Solin has been doing Moodle and Totara upgrades for over 15 years — from small single-tenant sites to multi-tenant enterprise installations with heavy customization. If you’d like a second pair of eyes on an upgrade plan, or want the whole thing handled end to end, get in touch.

Solin specializes in Moodle upgrades and version migrations.

Contact us