Large Moodle clones can consume enormous amounts of disk space and time. This guide identifies which tables and directories can safely be skipped for development or testing, and shows how to recreate missing tables afterward.
What to Skip When Cloning Large Sites
Some customers have very large Moodle installations, with literally hundreds of Gigabytes worth of data. In that case, you can skip:
The moodledata directory (you won’t be able to see any uploaded files such as scorms, videos or pictures though)
The mdl_files table when creating a database dump, as well as
the mdl_logstore_standard_log table
Recreating the Omitted Tables
If you’ve got a database dump without the mdl_files and mdl_logstore_standard_log tables, you need to recreate them in your own copy of the database. Use these SQL statements to do so:
CREATE TABLE `mdl_files` (
`id` bigint(10) NOT NULL,
`contenthash` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`pathnamehash` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`contextid` bigint(10) NOT NULL,
`component` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`filearea` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`itemid` bigint(10) NOT NULL,
`filepath` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`filename` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`userid` bigint(10) DEFAULT NULL,
`filesize` bigint(10) NOT NULL,
`mimetype` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`status` bigint(10) NOT NULL DEFAULT '0',
`source` longtext COLLATE utf8mb4_unicode_ci,
`author` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`license` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`timecreated` bigint(10) NOT NULL,
`timemodified` bigint(10) NOT NULL,
`sortorder` bigint(10) NOT NULL DEFAULT '0',
`referencefileid` bigint(10) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='description of files, content is stored in sha1 file pool' ROW_FORMAT=COMPRESSED;
--
-- Indexes for dumped tables
--
--
-- Indexes for table `mdl_files`
--
ALTER TABLE `mdl_files`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `mdl_file_pat_uix` (`pathnamehash`),
ADD KEY `mdl_file_comfilconite_ix` (`component`,`filearea`,`contextid`,`itemid`),
ADD KEY `mdl_file_con_ix` (`contenthash`),
ADD KEY `mdl_file_lic_ix` (`license`),
ADD KEY `mdl_file_con2_ix` (`contextid`),
ADD KEY `mdl_file_use_ix` (`userid`),
ADD KEY `mdl_file_ref_ix` (`referencefileid`);
--
-- AUTO_INCREMENT for dumped tables
--
--
-- AUTO_INCREMENT for table `mdl_files`
--
ALTER TABLE `mdl_files`
MODIFY `id` bigint(10) NOT NULL AUTO_INCREMENT;
CREATE TABLE `mdl_logstore_standard_log` (
`id` bigint(10) NOT NULL,
`eventname` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`component` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`action` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`target` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`objecttable` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`objectid` bigint(10) DEFAULT NULL,
`crud` varchar(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`edulevel` tinyint(1) NOT NULL,
`contextid` bigint(10) NOT NULL,
`contextlevel` bigint(10) NOT NULL,
`contextinstanceid` bigint(10) NOT NULL,
`userid` bigint(10) NOT NULL,
`courseid` bigint(10) DEFAULT NULL,
`relateduserid` bigint(10) DEFAULT NULL,
`anonymous` tinyint(1) NOT NULL DEFAULT '0',
`other` longtext COLLATE utf8mb4_unicode_ci,
`timecreated` bigint(10) NOT NULL,
`origin` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`ip` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`realuserid` bigint(10) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Standard log table' ROW_FORMAT=COMPRESSED;
--
-- Indexes for dumped tables
--
--
-- Indexes for table `mdl_logstore_standard_log`
--
ALTER TABLE `mdl_logstore_standard_log`
ADD PRIMARY KEY (`id`),
ADD KEY `mdl_logsstanlog_tim_ix` (`timecreated`),
ADD KEY `mdl_logsstanlog_couanotim_ix` (`courseid`,`anonymous`,`timecreated`),
ADD KEY `mdl_logsstanlog_useconconcr_ix` (`userid`,`contextlevel`,`contextinstanceid`,`crud`,`edulevel`,`timecreated`),
ADD KEY `mdl_logsstanlog_con_ix` (`contextid`);
--
-- AUTO_INCREMENT for dumped tables
--
--
-- AUTO_INCREMENT for table `mdl_logstore_standard_log`
--
ALTER TABLE `mdl_logstore_standard_log`
MODIFY `id` bigint(10) NOT NULL AUTO_INCREMENT;
Building Moodle for high availability on AWS requires separating state from compute: managed services like RDS, EFS, and ElastiCache handle persistent data while Spot instances serve stateless web traffic. This guide covers the full architecture, including failover strategies and critical configuration gotchas.
Glossary of AWS Terminology
EC2 (Elastic Compute Cloud): Virtual machines in AWS.
Spot Instance: Unused EC2 capacity sold at a discount. AWS may reclaim them with a 2-minute warning.
On-Demand Instance: Normal EC2 instance billed at the standard hourly rate with no risk of interruption.
ASG (Auto Scaling Group): Service that manages the scaling and lifecycle of EC2 instances.
ALB (Application Load Balancer): Layer 7 load balancer for web traffic.
RDS: Managed database service for MySQL, MariaDB, PostgreSQL, or Aurora.
EFS (Elastic File System): Managed NFS file store that can be mounted on multiple EC2 instances.
ElastiCache (Redis): Managed caching service used for Moodle MUC and optionally session caching.
1. Establish Shared Storage and State (The Stateless Layer)
In a Spot-based architecture, no EC2 instance should contain state that is required for proper operation. All persistent data must live in managed services.
Database (RDS or Aurora)
Purpose
Stores all Moodle application data, including course content, activities, logs, configuration, user accounts, enrolments, and (optionally) sessions.
Steps
Create a new RDS MySQL or Aurora MySQL instance. Recommended versions: MySQL 8.0 or Aurora MySQL 3.
Place the DB in private subnets in at least two Availability Zones.
Enable automated backups and set a retention period that matches business requirements.
Create a security group allowing inbound MySQL traffic only from:
The stable instance
The Spot web servers
(Optional) a bastion host or VPN
Set the database parameter group:
innodb_buffer_pool_size sized to available memory.
max_allowed_packet to at least 256M for large file uploads.
File Storage (EFS)
Purpose
Provides shared storage for moodledata, which must be accessible by all EC2 instances. Local disk storage cannot be used in a stateless architecture.
Steps
Create an EFS file system in the same region and VPC.
Enable Mount Targets in at least two Availability Zones in the private subnets.
Configure security group rules to allow NFS (TCP 2049) from all EC2 instances.
On each EC2 instance (stable and spot):
Install the EFS utilities.
Mount EFS to /var/www/moodledata (or your chosen location).
Set correct ownership and permissions:
Typical: www-data:www-data, u+rwX, g+rwX.
Performance Notes
Use General Purpose mode for most Moodle deployments.
Use Elastic Throughput unless running a very large site.
Caching (ElastiCache Redis)
Purpose
Provides fast shared cache storage for Moodle Universal Cache (MUC), session data (optional), and request-level caching.
Steps
Create an ElastiCache Redis cluster (cluster mode disabled is fine for most Moodle installs).
Place the cluster in private subnets.
Allow inbound traffic from the EC2 instances.
Configure Moodle to use Redis for:
Application cache
Session cache (optional, but recommended)
Notes
Do not use local file caching or local sessions on EC2 instances.
If using Redis for sessions, configure a replication group with automatic failover.
2. Configure the Stable Instance (Admin/Cron)
Role of the Stable Instance
This EC2 instance is not part of the Spot fleet. It runs on On-Demand pricing and is never interrupted. It performs all critical background processing:
Moodle cron
Scheduled tasks
Ad-hoc tasks such as course backups, grading operations, and import/export tasks
CLI maintenance actions
Long-running administrative operations
It effectively acts as the control plane of the Moodle application.
Configuration Requirements
Instance Type: Choose an instance with enough CPU and memory to handle all background tasks. Suggested minimum: t3.medium or t3.large.
Mount EFS: Mount the same EFS file system used by the Spot fleet.
Networking:
Instance must be in a private subnet.
Ensure outbound internet access through a NAT Gateway.
Security group must allow:
Outbound access for email and updates.
Inbound SSH from trusted locations.
Cron Setup: Add the following to root or www-data crontab:* * * * * /usr/bin/php /var/www/html/admin/cli/cron.php >/dev/null 2>&1
Web Traffic: It does not need to be in the ALB Target Group unless you want redundancy for admin access. The stable instance should not serve normal web traffic.
3. Configure the Spot Fleet (Web Servers)
These EC2 instances serve all user-facing traffic and can scale horizontally. They can be terminated at any time by AWS, so they must be fully stateless.
Auto Scaling Group (ASG)
Steps
Create an ASG in at least two Availability Zones.
Configure Mixed Instances Policy:
Majority Spot Instances
One or two On-Demand instances (optional) as capacity floor
Enable Instance Type Diversification:
Include 3 to 6 instance types across t, m, and c families.
Enable Capacity Rebalancing so AWS can replace at-risk Spot instances proactively.
Set the desired capacity, minimum, and maximum:
Example: min 2, desired 4, max 8.
Launch Template Configuration
The launch template defines how each web server is set up.
Required items
AMI (Ubuntu 24.04 or Amazon Linux 2023 recommended)
User Data script that:
Installs Apache or Nginx
Installs PHP and required extensions
Installs AWS EFS utilities
Mounts /var/www/moodledata
Pulls the Moodle code (Git, EFS, or CodeDeploy)
Configures Redis and DB settings via config.php
Starts the web server
Critical Requirement
The web servers must never run the Moodle cron. Ensure the user data does not write any cron entries.
4. Configure Load Balancing and Termination Handling
Application Load Balancer (ALB)
Steps
Create an ALB in public subnets.
Attach AWS ACM certificates for HTTPS.
Create a Target Group using HTTP or HTTPS.
Register only the ASG (Spot fleet) instances.
Configure health checks:
Path: /login/index.php or a custom health check script
Interval: 30 seconds
Healthy threshold: 2
Connection Draining (Deregistration Delay)
Connection Draining protects users when AWS terminates a Spot instance.
Steps
Open the Target Group settings in the AWS Console.
Set Deregistration Delay to 60 to 120 seconds.
Enable Connection Termination Protection if available.
How it Works
AWS gives a 2-minute warning before reclaiming a Spot instance.
The instance receives the termination notice via instance metadata.
The ASG marks the instance as terminating.
The ALB immediately stops routing new requests to that instance.
Existing user requests have up to 120 seconds to finish.
This prevents unexpected 500 errors during page loads.
5. Moodle Application Configuration
To complete the architecture, configure Moodle so that all stateful components use shared services.
Critical Settings
Sessions
Enable Database or Redis session storage.
Do not use file based sessions.
Path: Site administration > Server > Session handling.
Asynchronous Backups
Set to Enabled.
Path: Site administration > Courses > Backups > General backup defaults.
Reason: Ensures backups triggered by users running on Spot instances are delegated to the Stable instance.
Universal Cache (MUC)
Configure all cache stores (Application, Session, Request) to use Redis.
Path: Site administration > Plugins > Caching > Configuration.
Uploads longer than 2 minutes may fail if a Spot instance is reclaimed mid-upload, even with connection draining.
Cron must remain isolated on the Stable instance. Running cron on Spot instances can cause:
race conditions
tasks running twice
course backups failing
cron locks becoming stuck
EFS performance limits may apply to very large Moodle sites. For heavy workloads, consider:
EFS provisioned throughput
Local SSD cache combined with rsync or S3 (advanced)
Redis is a single point of failure unless deployed with replication and automatic failover.
Spot capacity fluctuations may cause scaling delays during high traffic periods.
Moving a Moodle install to a new server and domain requires careful pre-flight checks, code and database migration, DNS updates, and configuration adjustments. This guide covers the complete procedure with emphasis on avoiding downtime.
Pre-flight check.
Before proceeding, please double check that you have proper answers to the following questions at hand. Triple-check with the lead engineer if you have any doubts because a mistake throughout this process will result in publicly visible downtime at the very least.
Is the Moodle website using recaptcha? Unless the Recaptcha settings are modified the DESTINATION website will not have functioning captchas thus blocking users from registering and/or logging in.
Do you have root access to both servers?
Is the SOURCE website in maintenance mode? What window of time do you have for this process? If everything goes as expected you should be done in an hour or two, but if you encounter any hiccups it will take longer.
Do you have admin credentials for Moodle? What about root credentials for the database?
Does the DESTINATION server have the same post_max_size and upload_max_filesize php settings (usually in php.ini) as the SOURCE server? Otherwise users may run into upload limits of 2 MB.
Does the DESTINATION server have the same locales installed? Check this with locale -a (under Ubuntu Linux).
Are there any logging processes on the DESTINATION server that tend to eat up a lot of disk space, such as log_bin on MySQL? If so, turn them off if possible.
Does the DESTINATION server have enough free disk space? Check with df -h
apachectl configtest to test whether the config files are properly written.
service apache2 reload to process changes to the configuration.
The web server software: Apache (version 2.2 or 2.4, depending on the host OS version). This is important because some of the syntax has changed between those releases, but we will address that later in this document.
The database: MySQL.
The Moodle website folders, located on /home/<website name>/public_html. As you can guess, each website gets their own user. This will be relevant shortly.
The moodledata folder, usually located on /home/<website name/moodledata, contains files that are uploaded or created by the Moodle interface. The location of this folder will be specified in /home/<website name>/public_html/config.php.
First of all, we need to create the user and folder structure. Let’s get the uid of the SOURCE user:
As we can see, the uid is 1025. We will create a user with this same uid on the new system.
After creating it we are going to switch users, create the folder structure and assign the proper permissions so we can transfer files later.
If the UID were to be already in use, don’t worry. Create the user with a different UID and remember this when we are transferring the website files over to the destination server.
To create a backup of the database, first we need to know which database Moodle is using. This information can be obtained from the config.php file, like so:
And to back it up we use the following command:
This folder now contains the Moodle website, the moodledata folder and the database backup we just made. To transfer it to the new server, we are going to use Rsync and ask rsync to preserve all extra attributes (owner, permissions and so forth) with the “-a” switch.
“-a” will take care of assigning the proper permissions to the folder, so the files are accessible for Apache too.
This will take a while, and Rsync will keep us informed of the progress.
If earlier we had to create the user with a different UID, we can manually fix permissions in the DESTINATION server at this moment with the following set of commands.
Now that the website folder is in place, we need to add the site to Apache’s configuration. To do that, we will copy the original website from SOURCE:/etc/apache2/sites-available/<website>.conf into DESTINATION:/etc/apache2/sites-available/<website.conf>. We need to tweak some of the configuration parameters on DESTINATION, namely:
ServerName must match the new domain, if it changes. Same with ServerAlias (i.e. if the original website contained any ServerAlias instructions, they will have been copied over through the .conf file).
ErrorLog and CustomLog must have their paths modified to include the new domain name, if relevant.
To check for this, run apache2 -v in both the source and the destination. This will present a problem, as Apache made significant changes to the configuration directives between those two versions. You might encounter this scenario when the source server is Debian and the destination server is Ubuntu, due to their different packaging policies.
In this case, we are only concerned about one change: The way directory permissions are specified. We need to turn “allow from all” into “Require all granted”. The following is a snippet of an actual configuration file, before and after the change-
Before:
<Directory /home/12mprove/public_html>
Options -Indexes +IncludesNOEXEC +SymLinksIfOwnerMatch
allow from all
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
</Directory>
After:
<Directory /home/website/public_html>
Options -Indexes +IncludesNOEXEC +SymLinksIfOwnerMatch
Require all granted
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
</Directory>
If you do not check for this properly, Apache would error out when loading the new website configuration leading to downtime for every website on that server.
Moodle has a scheduled task that runs every minute, and takes care of running a variety of scheduled tasks at regular intervals (like sending mail, updating Moodle reports, RSS feeds, activity completions or posting forum messages).This scheduled task needs to be added to Apache’s crontab. To do so, we run the following command:
DESTINATION --: crontab -u www-data -e
And this is the line we need to add (careful, it is just one line with no breaks):
(It’s not necessary to use cgi-fcgi in order to run a specific php version on the command line.)
Running the “replace” script to update references to the domain name.
If we have changed the domain name, we need to run this tool so Moodle can update it’s internal references to the new domain. To do so, we run the following command on the DESTINATION server.
Updating the domain name in Moodle’s configuration
If the domain name has changed, you need to update /home/<website>/public_html/config.php so wwwroot points to the new domain name.
$CFG->wwwroot = 'https://<URL>';
In addition, you will also need to clear the caches (‘purge all caches’), especially for Totara, which caches the domain name for menu items like the gear icon (or cog wheel, in British English).
Adjust Quota
If you’re using something like Virtualmin to create the vhost, adjust the quota (usually 1GB or 2GB by default) – otherwise you will soon run into the limit.
Solving Database Connection Errors
If you run into any database connection problems, they may be due to using a recent MySQL version and an older Moodle version. Here are the known issues we have run into:
Authentication issue when using ‘traditional’ native MySQL native password
Using Moodle 2.x with MySQL 8
Authentication Issue
Add default-authentication-plugin=mysql_native_password to the [mysqld] section of /etc/mysql/mysql.conf.d.
Change the authentication method for the database user:
ALTER USER example-db@localhost IDENTIFIED WITH mysql_native_password BY 'thepassword';
Using Moodle 2.x with MySQL 8
If you are using a very old Moodle version, such as 2.x, together with a newer version of MySQL, e.g. version 8, then you will need to make some changes to the source code. You will also need to address the authentication mentioned above.
Change the code in lib/dml/mysqli_native_moodle_database.php, line 523:
$sql = "SELECT column_name as `column_name`, data_type as `data_type`, character_maximum_length as `character_maximum_length`, numeric_precision as `numeric_precision`, numeric_scale as `numeric_scale`, is_nullable as `is_nullable`, column_type as `column_type`, column_default as `column_default`, column_key as `column_key`, extra as `extra`
FROM information_schema.columns
To summarize: add aliases for every single column name.
It may also be necessary to make an additional change if you get the error message Unknown system variable 'storage_engine'.
Fix this issue by editing lib/dml/mysqli_native_moodle_database.php. Replace:
@@storage_engine
with
@@default_storage_engine
(In two places, in this case.)
There will still be a notice:
Notice: Undefined index: engine in /home/example-client/public_html/lib/dml/mysqli_native_moodle_database.php on line 173
But that will be ignored if you turn off full debugging mode.
Install a database backup script
The following is superseded by the section Configure backupvhost.php Script for Backups from “Installing a New Moodle Website on a VPS”.
Our data center (Hosting Provider) creates daily backups of the entire file system, for a window of fourteen days. This also includes the binary database files. But these can be hard to restore on another system. Therefore, we install a Bash script that creates a database dump and compresses it in one go. Here’s an example script:
This script creates a zipped database dump which will be included in the data center’s daily backup. Together with the public_html and the moodledata directories, this file can be used to completely restore a working Moodle installation on another system, should the need arise.
“The –single-transaction flag will start a transaction before running. Rather than lock the entire database, this will let mysqldump read the database in the current state at the time of the transaction, making for a consistent data dump.”
Create a db-backup directory in the home directory.
Use vim or another text editor to create the [customer]-backup.sh script inside the db-backup directory.
Set the owner to your users: chown -R [customer]:[customer] /home/[customer]/db-backup
Set the permissions to 770: chmod -R 770 db-backup
Give the database user (example-db in this example above) the proper permissions to user mysqldump: PROCESS (‘Manage processes’ in Webmin)
Check that the script is actually working properly: su (change user) to [customer] then run the script manually and check the contents of the zip file.
If you run into a disk quota issue: set the quota to unlimited for both the user and the group.
Create a cron job to execute the script on a daily basis, e.g. one hour before midnight (at that moment the data center starts their own backups). Use Webmin to create the cron job, but you can also use crontab:
@daily /home/[customer]/db-backup/[customer]-backup.sh #Creates a daily backup of the [customer] database (as a gz file)
The next day, check that the script has run properly (the timestamp should be shortly before midnight).
Here’s the Bash script without a specific customer’s name:
Typically, we have a Let’s Encrypt certificate installed for the domain, as well as the ‘www.’ version of the domain.
After migrating the site, run the following command to make sure the certificates are still in place and get renewed automatically:
certbot -d [thedomainname] -d www.[thedomainname]
(leave out the square brackets while typing in the command)
If there is no www.[thedomainname] version, skip that part.
You can test the results by visiting the website and checking that the certificate’s validity date starts today.
If you have certbot running on the old server, make sure you disable it for the old domain (unless you still need it).
Enabling the new website and testing that everything works.
To enable the new website, we need to run the following command on the DESTINATION server:
a2ensite <website>
service apache2 reload
You should be able to visit the domain now, and see the Moodle “maintenance” page. Log in with your admin credentials at https://websitedomain.com/login/ and browse through the courses. You should be able to access both courses and files, if the server is working correctly.
Test Email
Don’t forget to test the email delivery. To prevent the system from sending out emails to everyone before you’re ready, use this directive in config.php:
Now use the email test plugin to see if the system is still sending out email properly.
Disable Maintenance Mode
After you have checked that everything is working fine, use Administration > Site administration > Server > Maintenance mode to take the website out of maintenance mode.
Possible issues.
The embedded videos don’t work.
We use Vimeo as a video hosting service, and it is configured to only allow embedding from certain domains. If we are migrating to a new domain, we will need to add it to Vimeo’s settings so we can access the material.
Check with the lead engineer for more details.
The upload limits are too low.
Moodle allows users to upload files in a number of places. The upload limit (size of uploaded file) may be too low if the php.ini settings for the server have not been changed. You can check this under /admin/phpinfo.php – or Site administration > Server > PHP info – where you need to look for the post_max_size and upload_max_filesize settings. These should be 1000M. If they are not, change the php.ini file (you can't do that in Moodle, this is a server administration task).
Mailgun is no longer working
For Horizon, we had to switch to port 465 and ssl to get it working again, after migrating their websites to your server.
If you want to make sure that the system can send out email at all, use your own email provider (e.g. Gmail through smtp.gmail.com:465) to test.
Mailgun is no longer free. Consider using something like Mailjet.
MySQL Database Import Fails
If you can’t import a large DB dump because you encounter the following error message:
ERROR 2013 (HY000) at line 21770: Lost connection to MySQL server during query
Then check if you also have a memory error message somewhere. I had to edit /etc/mysql/mysql.conf.d/mysqld.cnf and change innodb_buffer_pool_size from 8589934592 to 4294967296:
Once we have taken the new Moodle site out of maintenance, we are going to redirect visitors from the old domain to the new one. To do this, we edit /etc/apache2/sites-available/<website>.conf and add a new Redirect directive.
RedirectMatch ^/(.*)$ https://<new domain>/$1
Streaming a tarred moodledata directory from SOURCE to DESTINATION
Overview
For very large moodledata directories (tens or hundreds of gigabytes), traditional copying methods like scp or rsync can be too slow or can run into memory/disk limits. A more robust approach is to stream a tar archive over SSH, unpacking it immediately on the DESTINATION server without creating a temporary tar file. This method:
avoids creating a giant .tar file on disk
avoids double I/O (read + write on both ends)
is resilient and efficient
can be monitored with pv (pipe viewer)
works across servers as long as firewall rules permit SSH traffic
This section documents the complete process.
Prerequisites
Before streaming moodledata:
You must be logged in on the SOURCE server.
The DESTINATION server must allow SSH connections from the SOURCE server’s IP address. (If the firewall blocks it, the stream will freeze at the SSH step.)
SSH public-key authentication must be working between SOURCE and DESTINATION.
You should use screen so that the transfer continues even if your session disconnects.
Understanding the Components
1. tar -C /path -cf – .
This command creates a tar archive on STDOUT instead of writing to a file.
-C /path changes into the moodledata directory before tarring. This ensures we put only the contents into the stream, not the folder itself.
-c means create archive
-f - means write the archive to stdout
. means "archive everything in this directory"
Example:
tar -C /home/webroot/leren/moodledata -cf - .
This produces a byte stream representing the entire moodledata contents.
2. pv (Pipe Viewer)
pv sits between the tar creation and the ssh transmission:
tar ... | pv | ssh ...
It shows:
total bytes streamed
current throughput
ETA
If pv is not installed, you can remove it — the stream will still work, just without progress feedback.
3. SSH agent forwarding (ssh -A)
If you connect to SOURCE from your laptop and then connect from SOURCE DESTINATION, you often need your local SSH keys available on the SOURCE machine. Using ssh -A forwards your SSH agent:
ssh -A source.example.com
This makes your laptop’s SSH key available transparently on SOURCE, so SOURCE can authenticate to DESTINATION without storing private keys on SOURCE.
If authentication fails or the agent doesn’t forward, DESTINATION will reject the connection.
4. Unpacking on DESTINATION (tar -C /path -xf -)
On DESTINATION, we unpack the incoming stream immediately:
-C /path = change into the target moodledata directory
Migrating a Moodle database from MySQL to PostgreSQL requires careful sequencing: installing PostgreSQL and pgloader, creating the target database, and handling data type conversions. This guide covers the practical workflow including pitfalls with Ubuntu's pgloader package and PostgreSQL restore procedures.
(You can specify that in /etc/mysql/mysql.cnf under the [mysqld] section. Simply add that section if it doesn’t exist. Don’t forget to restart the MySQL server: service mysql restart).
Also make sure that the MySQL user is using this authentication method:
ALTER USER 'sandbox'@'localhost' IDENTIFIED WITH mysql_native_password BY 'secretpassword';
Migrate MySQL Database to PostgreSQL
Use this command to do the actual migration (which is really a copy & convert, since the original MySQL database is left intact) :
This should select columns on fabrikamsandbox, but doesn’t:
SELECT a.attnum, a.attname AS field, t.typname AS type, a.attlen, a.atttypmod, a.attnotnull, a.atthasdef, pg_get_expr(d.adbin, d.adrelid) AS adsrc FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace as ns ON ns.oid = c.relnamespace JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid JOIN pg_catalog.pg_type t ON t.oid = a.atttypid LEFT JOIN pg_catalog.pg_attrdef d ON (d.adrelid = c.oid AND d.adnum = a.attnum) WHERE relkind = 'r' AND c.relname = 'mdl_context' AND c.reltype > 0 AND a.attnum > 0 AND (ns.nspname = current_schema() OR ns.oid = pg_my_temp_schema( ORDER BY a.attnum
It looks like pgloader does not populate the pg_catalog tables.
20240415
But in reality, the solution is very simple: in PostgreSQL, each database has a schema. By default, this schema is named public. If you don’t use the fully qualified name of a database object, such as a table, in PostgreSQL, then the public schema will be used implicitly.
Now, pg_loader names a new schema after the imported database. And since Totara does not explicitly mention the schema name, none of the Totara queries will work.
The solution is to connect to the database and then to simply rename the schema created by pgloader to ‘public’:
alter schema public rename to original_public;
alter schema fabrikamlive rename to public;
We can do this, because the PostgreSQL documentation states: “There is nothing special about the public schema except that it exists by default.”
–no-acl means “don’t include access control lists (privileges/grants) in the dump.”We don’t want “owners” and “privileges/grants” because they cause problems when restoring the dump if you don’t have the same owner and privileges in place.
The instructions were largely okay, but I had to replace the following cli command with versions which have double dashes (–) instead of a single long one:
Current issue: type casting issue arises when you do a query like
select id, username from mdl_user where username = ‘onno’ and delete <> 1;
The <> operator can’t handle whatever type 1 is, after importing the data and the schema through pgloader.
So I had Totara create the schema (simply do a Totara install and stop when they ask for the admin user data) and then asked pgloader to only load the data:
pgloader --with "prefetch rows = 1000" --with "data only" mysql://sandbox:passwd@127.0.0.1/fabrikamlive pgsql://postgres:passwd@localhost/fabrikamsandbox
SET session_replication_role = 'replica';
SELECT tablename FROM pg_tables WHERE schemaname = 'fabrikamlive';
-- Generate and execute the truncate command for each table
DO $$ DECLARE table_name TEXT;
BEGIN
FOR table_name IN (SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'fabrikamlive') LOOP
EXECUTE 'TRUNCATE TABLE ' || quote_ident(table_name) || ' CASCADE;';
END LOOP;
END $$;
SET session_replication_role = 'origin';
COMMIT;
20240416
2024-04-15T16:53:53.414533Z NOTICE COPY fabrikamlive.mdl_user with 41927 rows estimated [1/4]
2024-04-15T16:53:54.394558Z NOTICE COPY fabrikamlive.mdl_totara_sync_log with 41018 rows estimated [1/4]
2024-04-15T16:53:54.394558Z ERROR Database error 22P02: invalid input syntax for type smallint: "t"
CONTEXT: COPY mdl_user, line 1, column confirmed: "t"
Solved this issue by explicitly casting tinyint to smallint:
Using pgloader to import the schema for the dynamically generated mdl_appraisal_quest_data_* tables.
20240424
But then I found out that Totara has dynamically generated tables which are not created by Totara upon installation.
Unfortunately, using pgloader to do the entire migration, not just the data but also the schema, is not really an option as it leads to numerous errors and crashes.
… And it turns out that max_locks_per_transaction had somehow been set from 128 to 12, which explains the memory issues. Let’s quickly fix that…
Alas, the memory issues persist!
Next Steps
Find out how config file works for pgloader
With the existing database, fabrikamsandbox, import only the missing tables
This works, more or less, if we use a loader file to include only specific tables:
LOAD DATABASE
FROM mysql://sandbox:passwd@127.0.0.1/fabrikamlive
INTO pgsql://postgres:passwd@localhost/sampledb01
WITH include drop, create tables, reset sequences, prefetch rows = 1000, disable triggers, create no indexes
SET MySQL PARAMETERS
net_read_timeout = '1200',
net_write_timeout = '1200'
CAST type tinyint to smallint drop typemod
INCLUDING ONLY TABLE NAMES MATCHING ~/^mdl_appraisal_quest_data_[^\s]+/
;
[^\s]+ matches one or more characters that are not whitespace characters.
It might be necessary to free up some memory on the server, before running pgloader, by:
turning off the cron jobs for Totara (e.g. through Webmin)
Using pgloader to import the schema for the dynamically generated mdl_appraisal_quest_data_* tables.
pgloader script:
LOAD DATABASE
FROM mysql://sandbox:passwd@127.0.0.1/fabrikamlive
INTO pgsql://postgres:passwd@localhost/sampledb01
WITH prefetch rows = 1000, data only, truncate
SET MySQL PARAMETERS
net_read_timeout = '1200',
net_write_timeout = '1200'
CAST
type tinyint when (= precision 1) to smallint keep typemod
;
Execute, e.g.:
pgloader myloader.load
Various small issues:
Schema must be renamed from public to fabrikamlive: alter schema public rename to fabrikamlive; If you issue this command while being connected to database fabrikamlive, apparently the command is only executed for that particular database.
/etc/mysql/mysql.cnf must have default-authentication-plugin=mysql_native_password (and even then it might be necessary to do: ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_root_password';)
/etc/postgresql/14/main/postgresql.conf must have: max_locks_per_transaction = 128
If you have Totara create the database tables, be sure to include the ‘WITH truncate’ option in pgloader – see pgloader script above. Otherwise tables which have records will not be filled (mdl_user and mdl_course for instance).
20240928
I had to run VACUUM ANALYZE; on the fabrikamlive database. The catalog was loading very slowly (1 minute or more) for a specific user. VACUUM ANALYZE; fixed this.
Actually, the real issue was with the online content marketplaces feature, which does an extremely inefficient has_capability_in_any_context check.
Live branch was at commit 1daa62ced.
20241025
After importing a Postgres DB it is usually necessary to:
Change the schema to public
Change the ownership of all tables to your DB user
Ad 1:
DO $$
DECLARE
tbl RECORD;
BEGIN
FOR tbl IN
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'fabrikamlive'
LOOP
EXECUTE format('ALTER TABLE fabrikamlive.%I SET SCHEMA public;', tbl.table_name);
END LOOP;
END $$;
Ad 2:
DO
$$
DECLARE
r RECORD;
BEGIN
FOR r IN SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' LOOP
EXECUTE 'ALTER TABLE public.' || quote_ident(r.table_name) || ' OWNER TO fabrikamsand2;';
END LOOP;
END
$$;
I also found out I had to do these, on one occasion:
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT c.oid::regclass AS obj
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname='public' AND c.relkind IN ('r','p') AND c.relname LIKE 'mdl_%'
LOOP
EXECUTE format('ALTER TABLE %s OWNER TO fabrikamstaging;', r.obj);
END LOOP;
END$$;
-- Sequences
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT c.oid::regclass AS obj
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname='public' AND c.relkind='S' AND c.relname LIKE 'mdl_%'
LOOP
EXECUTE format('ALTER SEQUENCE %s OWNER TO fabrikamstaging;', r.obj);
END LOOP;
END$$;
(Or simply use ChatGPT to produce the code with your specific user and database inserted right away.)
Recommended PostgreSQL Database Handling
Create a separate PostgreSQL user (can have the same name as the database)
sudo -u postgres psql
CREATE USER moodleuser WITH PASSWORD 'yourpassword';
(If user already exists): ALTER USER moodleuser PASSWORD 'new_password';
Simply keep all tables in the public schema (or move them into there when importing)
Use password authentication (as opposed to peer authentication, which uses the Unix user in the background) to access the database as the user, e.g.:
When restoring a PostgreSQL database for Moodle or Totara, it’s common to encounter permission and ownership problems such as:
ERROR: permission denied for table mdl_config
ERROR: cannot change owner of sequence ... because it is linked to table ...
These errors occur because PostgreSQL, unlike MySQL, preserves exact ownership metadata from the original dump. Every table, sequence, and schema has an explicit owner. If you restore the dump as the postgres superuser, all objects will be owned by postgres—but Totara will later connect as its own role (e.g. totara) and lack permissions to manage those objects.
MySQL does not enforce this distinction: the importing user automatically “owns” everything in the database, so these issues never appear there.
To prevent these conflicts and ensure smooth operation during upgrades or plugin installations, you must import the database as the application’s own user, not as postgres.
Correct Restore Template
This 6-line process guarantees that all imported tables, sequences, and schemas are owned by the intended role.
# 1. Switch to the postgres system user
sudo -u postgres psql
# 2. Create the database user (role)
CREATE USER totara WITH PASSWORD 'strongpassword';
# 3. Create the database owned by that user
CREATE DATABASE totara OWNER totara ENCODING 'UTF8' TEMPLATE template0;
# 4. Quit psql
\q
# 5. Import the dump *as the totara user*
psql -U totara -d totara -h 127.0.0.1 -f /path/to/your_dump.sql
That’s all you need—no post-import ALTER OWNER scripts, no schema renames, no reassign loops.
Additional Notes
If the dump was created with pg_dump -Fc, use pg_restore instead:pg_restore -U totara -d totara /path/to/your_dump.dump
Ensure pg_hba.conf allows local password authentication for the totara user.
Always verify ownership after import:\dt+ public.mdl_user
Owner should be totara.
In config.php, make sure the dbhost variable is not set to ‘localhost’, but this instead:
Why PostgreSQL Is Stricter
PostgreSQL’s role system enforces fine-grained security:
Each object (table, sequence, view, function, etc.) has a single explicit owner.
Linked sequences must share ownership with their tables.
Privileges do not cascade automatically.
This model increases safety and maintainability in multi-application environments—but requires a bit more care during setup and migrations.
MySQL, by contrast, automatically grants implicit ownership of all imported objects to the current user, masking these details.
Summary
Following the procedure above ensures Totara (or Moodle) can:
Run upgrades and install plugins without permission errors
Create and modify tables during schema changes
Access sequences and perform inserts normally
SOP: Plain-text PostgreSQL dump/restore (sudo -u postgres, no owner/ACL)
0. Summary
Dump as plain SQL with --no-owner --no-acl --blobs, run as the postgres OS user.
Create one owner role and one database on the target.
Restore as that owner. Minimal hassle, version-friendly.
REVOKE ALL ON DATABASE newdb FROM PUBLIC;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
ALTER SCHEMA public OWNER TO app_user;
ALTER ROLE app_user IN DATABASE newdb SET search_path = public;
SELECT extname, extversion FROM pg_extension ORDER BY 1;
Quick row counts (example tables):
SELECT COUNT(id) FROM mdl_user
6. Common pitfalls and fixes
Error about permissions during restore: ensure you are restoring as app_user, and that public is owned by app_user (see hardening block).
Role "someone" does not exist: indicates owners/ACLs leaked into the dump. Re-dump exactly as above with --no-owner --no-acl.
Version differences: plain SQL is generally fine. If you hit extension issues, precreate allowed extensions on the target, or drop/adjust unsupported ones.
7. Optional: one-liner remote pull (no intermediate file on source)
From a machine that can reach the source and target:
That’s it. This variant keeps everything simple, avoids passwords on the dump step, and restores cleanly under a single owner on the target.
Setting up a new Moodle instance on a VPS requires careful configuration of DNS, virtual hosts, and web server settings. This guide covers the complete workflow from domain setup through initial Moodle installation.
1. Create a DNS record
There are two options here (described in more detail below):
The customer has access to their own DNS server or provides you with access data: link their preferred (sub)domain to the ip address of your VPS server.
Create a DNS Record for a Subdomain under Our Domain: this means that you have to adjust the DNS settings for one of your own domains, to link a subdomain to the VPS.
Link the Customer’s Preferred (sub)domain to the IP Address of your VPS Server
If your users already has a (sub)domain name for their new Moodle installation, you have to go into their own DNS server and create the necessary changes. Since there’s no telling what DNS server or provider they’re using, the exact steps vary by DNS provider.
The outcome should usually be an A record that links their (sub)domain to the ip address of your server:
lms.westwood.com. IN A 123.456.789.101
In short: their (sub)domain should be link to our ip address.
Create a DNS Record for a Subdomain under Our Domain
2. Create a VirtualHost
Log in to Virtualmin on the your-vps server: https://198.51.100.42:10000/?virtualmin.
Then select “Create Virtual Server”:
Input the details for the new virtualhost:
Please note:
Use the subdomain as the Administration username (use the “Custom username” feature). E.g. if the domain is westwood.example-lms.com, then ‘westwood’ becomes the Administration username.
Create a strong, random password and store it in a very secure password wallet (e.g. KeePassX).
If you use Virtualmin, be sure to adapt the quota (usually 1GB by default) – otherwise larger Moodle installations will run into the limit. (MySQL server will even crash entirely if you try to import data beyond the quota limit. I once had to manually delete the database directory from /var/lib/mysql/ to get it working again, because the MySQL server wouldn’t even start in recovery mode.) To double check whether a quota limit applies:
root@blueyondercoaching:~# quota -u example-db
Disk quotas for user example-db (uid 1020): none
If the database creation fails for some reason, simply use: create database mydatabasename character set utf8mb4 collate utf8mb4_unicode_ci;
3. Set the Database Password
After completing the previous step, click “Create Server” (see previous screenshot).
If the virtual server has been set up, click on “Webmin” in the top left corner of the window:
In the blue panel on the left, select “Servers” > “MySQL Database Server. Then scroll down and click “User Permissions” (Under “Global Options”)::
Then click on the database user with the name of the new subdomain (i.e. the Administration username), Westwood in this example:
Now type in a strong, random password and store that password in your password wallet (e.g. KeePassX):
Click “Save” to complete this step.
Solving Database Connection Errors
If you run into any database connection problems, they may be due to using a recent MySQL version and an older Moodle version. Here are the known issues we have run into:
Authentication issue when using ‘traditional’ native MySQL native password
Using Moodle 2.x with MySQL 8
Authentication Issue
Add default-authentication-plugin=mysql_native_password to the [mysqld] section of /etc/mysql/mysql.conf.d.
Change the authentication method for the database user:
ALTER USER example-db@localhost IDENTIFIED WITH mysql_native_password BY 'thepassword';
Using Moodle 2.x with MySQL 8
If you are using a very old Moodle version, such as 2.x, together with a newer version of MySQL, e.g. version 8, then you will need to make some changes to the source code. You will also need to address the authentication mentioned above.
Change the code in lib/dml/mysqli_native_moodle_database.php, line 523:
$sql = "SELECT column_name as `column_name`, data_type as `data_type`, character_maximum_length as `character_maximum_length`, numeric_precision as `numeric_precision`, numeric_scale as `numeric_scale`, is_nullable as `is_nullable`, column_type as `column_type`, column_default as `column_default`, column_key as `column_key`, extra as `extra`
FROM information_schema.columns
To summarize: add aliases for every single column name.
It may also be necessary to make an additional change if you get the error message Unknown system variable 'storage_engine'.
Fix this issue by editing lib/dml/mysqli_native_moodle_database.php. Replace:
@@storage_engine
with
@@default_storage_engine
(In two places, in this case.)
There will still be a notice:
Notice: Undefined index: engine in /home/example-client/public_html/lib/dml/mysqli_native_moodle_database.php on line 173
But that will be ignored if you turn off full debugging mode.
For Totara: Grant Permissions to Create Tables & Indexes
If you’re installing Totara, please make sure the database user has adequate permissions to create caching tables and indexes. (For the report builder.)
Using The MySQL Client To Create Database And DB User
Instead of using Webmin, you can also use the commandline tool mysql:
mysql -u root -p
CREATE DATABASE moodle_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'moodle_user'@'localhost' IDENTIFIED BY 'your_strong_password';
GRANT ALL PRIVILEGES ON moodle_db.* TO 'moodle_user'@'localhost';
FLUSH PRIVILEGES;
4. Create a Git Repository
Before we install or deploy the code for the new Moodle website, we need to set up a Git repository. In this guide this guide uses Bitbucket to store the remote repository, but any other system is fine too (e.g. Gitlab or Github).
New Bitbucket Repository
On Bitbucket.com, use the ‘Create’ button to add a new repository:
Bitbucket will also ask you to create a Project. Lately, I’ve made it a habit to use your users’s name for the project, and your users’s name for their platform (Moodle or Totara) as the name for the repository.
Clone Repo and Get the Moodle Code
On your local machine, clone the repo. Then add the Moodle repo as the upstream repo:
Get a specific branch. Here, we’re pulling the branch containing Moodle 4.1 because it is the current LTS version (the upstream branch name can be found in the upstream repo):
git pull upstream MOODLE_401_STABLE
Create a local branch based on the upstream branch:
Then copy all the files from moodle41 to the current branch:
git checkout moodle41 .
Yes, this looks like we’re switching to another branch altogether, but notice the dot. This apparently means: get the files from branch moodle41 and put them in the currently checked out branch (which is the master branch in this example).
After you’re done, commit and push the changes to remote.
Please note that we can’t add the Totara source code in this way because Totara is not really open source: there is no publicly available Git repository for it.
Caveat: 3rd Party Plugins
Please note: the method described above also works if you allow users to install their plugins (through the standard Moodle interface). But you wouldn’t have the complete code base in your git repo of course.
However, this still needs to be executed on your local machine since it requires a commit afterwards. From the link above:
$ cd /path/to/your/moodle
$ git submodule foreach git pull
The command git submodule foreach [another command] walks through all submodule repositiories and executes what is specified by [another command]. In this case it is git pull. Therefore, the module mylittleextension was updated and the main repository isn't clean anymore until changes are committed:
$ git commit -a -m "Plugin updates"
Since the git users on the live servers do not have write access to the repository, we can’t do the above on the live server itself. (We don’t want to give them write access because their private ssh keys are on the web server, accessible for everyone on that server).
Add Public Key of Vhost’s Unix User to Bitbucket
We are going to use Git to deploy the new code (and also later, e.g. for minor and major Moodle upgrades). Login on the web server and:
su to the unix user for the website
Execute the following command: ssh-keygen
Skip the passphrase – we don’t want to use a passphrase (but only in this case)
Put the public key in Bitbucket repo through “Repository settings > Access keys”
Provide a clear label, e.g. “mbcc user on your-vps”
Clone the repository: ‘git clone’. Make sure the directory containing the git repository is the webroot (usually called public_html or htdocs)
Set the permissions, e.g.: chmod -R 755 public_html
Secure the .git folder by doing a chmod -R 700 .git (we’ve had complaints from security officers that the .git folder was public, even though what’s in it is typically public anyway since it’s open source software).
5. Install & Configure Moodle
Make sure the (sub)domain can be reached, e.g.:
You will get an error message ‘Forbidden’, but that’s okay since there’s no actual website yet. If something went wrong, you’ll see a different message, e.g.: “This site can’t be reached”.
Login to the VPS, e.g.:
engineer@opsbox-computer14:~$ ssh sysadmin@198.51.100.42
Welcome to Ubuntu 16.04.3 LTS (GNU/Linux 4.4.0-042stab123.9 x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/advantage
Last login: Sun Aug 27 08:34:07 2017 from 83.162.44.210
sysadmin@yourserver:~$
Move to your users’s home directory:
sysadmin@yourserver:~$ cd /home/westwood
sysadmin@yourserver:/home/westwood$
If you have deployed the code through Git, which is the preferred way, you can skip ahead to step 11: Make a copy of public_html/config-dist.php. Otherwise, go to https://download.moodle.org/releases/latest/ and click on the download button for the package that is built weekly:
(Cancel the download to your local machine.) Copy the download link:
Edit the contents of public_html/config.php with your text editor, e.g. vim:
$CFG->dbtype = 'mysqli'; // 'pgsql', 'mariadb', 'mysqli', 'mssql', 'sqlsrv' or
$CFG->dblibrary = 'native'; // 'native' only at the moment
$CFG->dbhost = 'localhost'; // eg 'localhost' or 'db.isp.com' or IP
$CFG->dbname = 'westwood'; // database name, eg moodle
$CFG->dbuser = 'westwood'; // your database username
$CFG->dbpass = 'the_secret_password'; // your database password
$CFG->prefix = 'mdl_'; // prefix to use for all table names
$CFG->dboptions = array(
'dbpersist' => false, // should persistent database connections be
'dbsocket' => false, // should connection via UNIX socket be used?
'dbport' => '', // the TCP port number to use when connecting
'dbhandlesoptions' => false,// On PostgreSQL poolers like pgbouncer don't
'dbcollation' => 'utf8mb4_unicode_ci', // MySQL has partial and full UTF-8
);
$CFG->wwwroot = 'http://westwood.example-lms.com';
$CFG->dataroot = '/home/westwood/moodledata';
The lines that need changing are highlighted. This is an excerpt from a config.php file, not a complete config.php file.. Do not replace the code of your config.php file with this code, but make the changes in the file itself (new code may have been added by the Moodle core team).
Secure the Moodle Installation
Make sure the directories have the right permissions and ownership (for the username, we’re following the ‘westwood’ example – change this to match the client’s username):
sudo chown -R westwood:www-data public_html
sudo chmod -R 0755 public_html
sudo chown -R westwood:www-data moodledata
sudo chmod -R 0750 moodledata
Also consider closing public access to the .git folder if it’s in your webroot. Even though most of the code is open source, some clients are still paranoid about it.
Go to the website to actually install Moodle. It should redirect you to /admin/index.php:
Just follow the installation wizard from here on (ignore any notifications about https).
In the installation form (see screenshot below):
The username should be either siteadmin, yoursiteadmin or example-siteadmin (again, whatever is appropriate).
Use admin@example-lms.com, admin@your-domain.com or admin@example.com as the admin’s email address (whatever is appropriate, e.g. if the site is Frooxl’s then it should be admin@example-lms.com).
Set Email display to “Hide my email address from everyone”.
For the Front page settings, use the subdomain name (or your users’s chosen name for the LMS) as the “Full site name” and the “Short name for site”. Set the “Default timezone” to whatever timezone the site is in.
Create an Admin Account
In the panel (menu) on the left, click on “Site adminstration”, then select the “Users” tab. Click the “Add a new user” link.
Fill out the minimally required fields (see screenshot below):
Username: your users’s company name, followed by ‘admin’. E.g.: westwoodadmin.
Generate a strong, random password and store it securely (e.g. in KeePassX), together with the url and the username. Also fill in the password in the current Moodle form.
Use ‘Admin’ as the first name and the company name as the last name (e.g. “Westwood”).
If you’ve got a role based email address, use that, preferably the admin address – e.g. admin@westwood.com.
Add the new customer’s account as a site administrator. Select “Site administration” > “Users” > “Site administrators” (see screenshot below):
Select your users’s user and click “Add”:
Afterwards, the form should look like this:
(The customer’s account should appear under “Current site administrators”).
Add Outgoing Mail Configuration
Go to admin/settings.php?section=outgoingmailconfig and either:
Add your users’s SMTP settings
Or create a Mailgun account for your users (see Mailgun and Moodle Integration).
Change Upload Limits
By default, Moodle's upload limits are just one or two measly MBs. You need to change this upload limit to 1000 MB in a number of places:
On the server: in the php.ini file, you need to set post_max_size and upload_max_filesize to 1000M (this is typically already done, you can check this under /admin/phpinfo.php – or Site administration > Server > PHP info)
Under Site security settings (Site administration > Site security settings), where you need to select 1000 MB for "Maximum uploaded file size".
And finally for individual modules, by default at least the assignment module (mod assign), under /admin/settings.php?section=assignsubmission_file (go to Site administration > Plugins, select "File submissions"). This setting, again, needs to be 1000 MB.
Turn Off Update Notifications
Out of the box, Moodle will send out update notifications by email whenever a new version is available. This is alarming for some customers because they tend to think the software is outdated. Turn this feature off:
Site administration > Server > Update notifications
Disable the checkbox “Automatically check for available updates”.
Enable Apache X-Sendfile for large file delivery
Large Moodle sites usually serve videos, H5P files, SCORM packages, PDFs, and large images. By default these files are streamed through PHP-FPM, which reduces performance and limits concurrency. Enabling Apache’s X-Sendfile offloads file delivery to Apache, which is significantly faster.
Step 1: Install and enable mod_xsendfile
apt update
apt install libapache2-mod-xsendfile
a2enmod xsendfile
service apache2 restart
Step 2: Add X-Sendfile directives to the VirtualHost
Inside the VirtualHost configuration:
<IfModule mod_xsendfile.c>
XSendFile on
XSendFilePath /home/USERNAME/moodledata
XSendFilePath /home/USERNAME/public_html
</IfModule>
If the file downloads correctly, Apache X-Sendfile works.
Aside: If you later observe slow requests
If the site suffers performance issues when users view large files, verify X-Sendfile is actually in use by running the Moodle-level probe described in the troubleshooting guide.
When X-Sendfile is active, PHP-FPM usage for large files drops dramatically and site responsiveness improves.
6. Create a Cron Job
On your users’s Moodle website, select: “Site administration” > “Site administration” (tab) > “Site policies”:
On the next page, scroll down and:
Disable “Cron execution via command line only”.
Set a strong, random password for “Cron password for remote access”. But do not use any special characters in the cron password, because this password is used in a querystring parameter (i.e. in a url). Store the password in secure location (e.g. KeePassX).
Now test the url in your browser. In this example, the url is: http://westwood.example-lms.com/admin/cron.php?password=your_cron_password
Go to Webmin and select “System” > “Scheduled Cron Jobs”. Click on one of the existing cron jobs, e.g. the one for example.com:
In the “Command” field, replace the original url (example.com… in this example) with the new customer’s url. Then click on “Create” at the bottom of the screen.
7. Configure backupvhost.php Script for Backups
A simple solution for creating differential backups. Here is the script we are using:
On each server, there is a user backupvhost who has an ssh key pair. The public key is stored on the Strato server to enable authentication without the need of specifying a password (there is no passphrase).
Please note: there will always be one additional full backup to make sure the incremental parts from the previous period can also be restored.
Installation of Backup Script on Server
To get the backup script working on a new server, follow the steps below (see the Git repo for the most recent version of these instructions). If you already have the backup script installed on the server, skip to the next section.
Create a 'backupvhost' unix user on the web server.
Add the 'backupvhost' user to the user group of the vhost's unix user (add vhost unix user to backupvhost’s secondary group in Webmin)
Generate a public / private ssh key pair for the backupvhost user.
Add the public key to the git repo for the backup script.
Install this script in a 'scripts' folder in the home directory of the backupvhost user using git
Create a backup directory in the home directory of the backupvhost user. You should have the following files and folders now:
Add the public key of the backupvhost user to the destination server.
Initiate a very small scp copy to the destination server and answer yes on the following question:The authenticity of host 'scp.hidrive.strato.com (85.214.3.70)' can't be established. ED25519 key fingerprint is SHA256:RZwN5ypH59Jssd3qVhuAnCn3fEtveYCrF24igD8QDBs. This key is not known by any other names Are you sure you want to continue connecting (yes/no/[fingerprint])?
Add a folder {source hostname}_server_backups on the destination server.
On the source server, grant the right permissions to the script:
chmod 770 scripts/backupvhost.php
chown backupvhost scripts/backupvhost.php
Create a folder/users/solin/{source hostname}_server_backups/{databasename}/ on the destination server for each vhost that you want to backup.
Test the script manually. Remove the results afterwards from the destination server. Calling the script should look something like this:
/usr/bin/php7.2 /home/backupvhost/scripts/backupvhost.php --config="/home/{unix user for vhost}/public_html/config.php" --hostname="{source hostname}" >/dev/null
Run this script as user 'backupvhost', typically as a daily cron job (choose a time around midnight, but pay attention to other backups jobs so as not to tax the system too much at any given time).
Check the backup results the following day on the destination server.
Creating a Cron Job to Call the Backup Script for a New Moodle Installation
To get the backup script working for a new Moodle installation, follow these steps:
Create a folder/users/solin/{source hostname}_server_backups/{databasename}/ on the destination server (currently my.hidrive.com) for the vhost that you want to backup. (source hostname is typically yyour server’s name).
Add vhost unix user to backupvhost’s secondary group in Webmin.
Do the same for www-data and set the correct permissions e.g.:usermod -a -G www-data backupvhostchmod -R g+rX /home/example-db/moodledata
Test the script manually (through a screen session). Remove the results from the destination server afterwards. Calling the script should look something like this:
su backupvhost;
/usr/bin/php7.2 /home/backupvhost/scripts/backupvhost.php --config="/home/{unix user for vhost}/public_html/config.php" --hostname="{source hostname}" >/dev/null
Run this script as user 'backupvhost', typically as a daily cron job (choose a time around midnight, but pay attention to other backups jobs so as not to tax the system too much at the same time).
Check the backup results the following day on the destination server.
Previously: Install a database backup script [Out of Date]
Our data center (Hosting Provider) used to create daily backups of the entire file system, for a window of fourteen days. They don’t do that anymore, but here is how we used the daily backups in the past.
The daily backups also include the binary database files. But these can be hard to restore on another system. Therefore, we install a Bash script that creates a database dump and compresses it in one go. Here’s an example script:
This script creates a zipped database dump which will be included in the data center’s daily backup. Together with the public_html and the moodledata directories, this file can be used to completely restore a working Moodle installation on another system, should the need arise. Please see the document Restoring a Moodle Backup from HostEurope for more details on how to restore a backup.
Steps to get the Bash script working
Create a db-backup directory in the home directory.
Use vim or another text editor to create the [customer]-backup.sh script inside the db-backup directory.
Set the owner to your users: chown -R [customer]:[customer] /home/[customer]/db-backup
Set the permissions to 770: chmod -R 770 db-backup
Give the database user (example-db in this example above) the proper permissions to user mysqldump: PROCESS (‘Manage processes’ in Webmin)
Check that the script is actually working properly: su (change user) to [customer] then run the script manually and check the contents of the zip file.
If you run into a disk quota issue: set the quota to unlimited for both the user and the group.
Create a cron job to execute the script on a daily basis, e.g. one hour before midnight (at that moment the data center starts their own backups). Use Webmin to create the cron job, but you can also use crontab:
@daily /home/[customer]/db-backup/[customer]-backup.sh #Creates a daily backup of the [customer] database (as a gz file)
The next day, check that the script has run properly (the timestamp should be shortly before midnight).
Here’s the Bash script without a specific customer’s name:
To check that the website is up at all times, we add a “Remote HTTP Service” monitor to a Webmin installation. This should be done on another web server than the one hosting the current Moodle site.
Go to Tools > System and Server Status and click on the button Add monitor of type, while HTTP Request is selected. This should take you to the following screen:
Set the field “Connection timeout” to 10 seconds. This should also notify you if the loading times for the Moodle website get unacceptable (i.e. more than 10 seconds).
9. Share Access Credentials
If the Moodle installation is completely ready, and no further content editing is required, notify your users directly. In your email, include:
Url for the new Moodle installation
Username
Password
At any rate, send the company’s Moodle admin account (username and password) for this installation to the lead developer (the lead engineer, at the time of writing), including MySQL's password, the Unix password and the url. Be sure to do that in a secure way.
HostEurope's backup system stores 14 days of snapshots that can be selectively restored via their KIS console. This guide covers navigating the restore interface, choosing backup dates, and handling the restored files once recovered.
Introduction
Our data center, Hosting Provider, creates daily backups of the entire file system of a VPS (Virtual Private Server), for a window of fourteen days. This also includes the binary database files. But these can be hard to restore on another system. Therefore, we install a Bash script for each virtual host (i.e. Moodle website) that creates a database dump and compresses it in one go.
The advantage of this is that a database dump, together with the moodledata directory and Moodle’ s codebase, allows you to restore the complete Moodle installation on any system which meets the minimum requirements for the Moodle version, not just the original server.
Please see the document Installing a New Moodle Website on a VPS for more details on how the backup script works exactly.
1. Create A Restore Directory
HostEurope has the option to restore a specific directory of the file system in the backup (up to 13 days) to a directory of your choice. For instance, you can choose to restore /home/mensen/ to /restore. Either in full, or as a tar.gz file.
This means that performing a restore will take up additional disk space. But it’s also extremely easy to execute.
To create a restore directory, login to the VPS using ssh and issue a command like:
Now pick the system on which you want to perform the restore, by clicking on the Konfigurieren button (the KIS website is originally in German, which pops up in odd places in the English version).
Finding The Right VPS
If you don’t know which system (VPS) has the Moodle installation you’re looking for, use the command nslookup with the domain name as its argument. For instance, if I want to know the IP address of academy.civic-rights.example:
Next to Datum des Backups (which means backup date), click on the select box and pick the right date. Please keep in mind that the backup is usually created around midnight CET.
Next, click on the Browse button next to Quell-Verzeichnis (source directory).
In the following screen, you’ll get to see the file system as it was at the time the backup was made. On your VPS systems, we always store the files for the Moodle installations in their own home directories. So click on home first:
Find the home directory of the Moodle installation you want to restore, which typically has a part of its domain name as the directory name.
Now click on the auswählen (choose) link next to the home directory’s name, mensen in this example:
3. Perform The Restore
Once you have selected the source of the backup, you’ll be presented with the following screen, on the KIS website:
Here’s a translation and clarification for the fields in the form:
Datum des Backups: backup date
Quell-Verzeichnis: source directory – this is the directory you have created on the VPS in the first step.
Benachrichtigungen an E-mail: e-mail notification – the system will send you an email once the restore is done.
Restore-Type: restore type – do you want to restore the target directory in its entirety (Direkt) or as a compressed file?
If you have filled out the form click Restore starten (Start restore).
The restore process will take some time (since it’s presumably copying the files from a slow backup medium).
Depending on the purpose of the restore (e.g. disaster recovery vs. restoring a single Moodle course based on a user’s request), you might want to check other SOPs on how to get the Moodle backup up and running. If the Moodle site needs to be installed on a new system, please see the document SOP Migrating a Moodle install to a new server and domain.
Once the restore is complete, don’t forget to inform your users of the results.
Considerations
Don’t look at Alpine Vista’s backup solution – it’s too complicated.
Instead, rely on a rotation scheme (e.g. as provided by /etc/logrotate.d) to remove old backups out of the window. Although this probably can’t be done on the storage server if it’s really only a file server you can only access through sftp. In that case we need something that cycles through the numbers 1 – 14. E.g.:
$backupday = $weekday;
if ($weeknumber % 2 == 0) {
// even week number
$backupday = $backupday + 7;
}
(The script should probably also remove yesterday’s backup before creating a new one to be copied to the remote system).
Also consider encrypting the backup file on the fly:
But these tend to be too complicated for our use case.
Incremental Backups
20230601
Large moodledata directories typically contain files that are added once but never changed afterward, such as video files. It doesn’t make much sense to store a complete copy of moodledata every day.
Instead, we need to create a full backup on day 1 (level 0, in tar terminology) and incrementally add to that until day 14 (or whatever the backup window is).
You can force ‘level 0’ backups either by removing the snapshot file before running tar, or by supplying the ‘–level=0’ option
It is common practice to name meta files with the extension .snar, think of this as an amalgamation of snapshot and tar.
It is also possible to create differential backups: these take the full backup as the base and then subsequently store all the mutations since the full backup was made. The difference is this: incremental backups store only the mutations since the last backup, whereas differential backups may store the same file multiple times (a file that was newly added since the full backup).
There is a user backupvhost on the La Forge server who has an ssh key pair. The public key is stored on the Strato server to enable authentication without the need of specifying a password (there is no passphrase).
Please note: there will always be one additional full backup to make sure the incremental parts from the previous period can also be restored.
Troubleshooting
20230602
Not working:
INM – destination folder on Hidrive was incorrectly named
Leerhuis – was no longer present in backupvhost cron job
Civic Rights Academy – first no cron job and then option --hostname="your-vps" was missing
20230619
Not working:
Kwil – backup script must run under php 5.6 since it’s going to include config.php, which in turn reads some of the Moodle files. This is Moodle 2.6, so php 5.6 is needed, not php 7.2
Alpine Vista – not clear why backup wasn’t created overnight. Test run works fine. I’ll have to keep an eye on it.
20230620
Alpine Vista may still not be working: there was a fresh full backup made at 1:51 am, but today is day 2 (Tuesday, not Monday) so we would have expected a day2 backup… Let’s keep an eye on it.
20230621
Alpine Vista is one day behind. Other than that, everything seems to be working fine. The cron job was set to work at 1:50 am, so maybe some date in the system is still at the previous day. I did another run, resulting in a ‘backup_day03’ file. I’ve also changed the cron job to run at 3:50 am.
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.)
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:
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:
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:
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)';.
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:
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:
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:
Confirm all third-party plugins are committed to the branch.
Put Moodle in maintenance mode: Site administration → Server → Maintenance mode, or visit /admin/settings.php?section=maintenancemode.
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.
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.
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.
Restore config.php from the backup of the old code base.
Visit /admin/ to check for plugin issues before triggering the upgrade.
Start a screen session and run the upgrade:
php admin/cli/upgrade.php
Run the same smoke tests you ran on staging: email, cron, custom theme, integrations.
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.
Take the site out of maintenance mode.
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:
Fetch upstream and merge the latest minor tag into your site branch:
git fetch upstream
git merge v4.5.4
Push, then pull on the server.
Enable maintenance mode.
Run php admin/cli/upgrade.php --non-interactive.
Disable maintenance mode.
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:
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.