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.

Config.php Adjustments Typical minimal adjustments:$CFG->sessionredis_host = 'redis.example.internal';

$CFG->session_handler_class = 'core\session\redis';
$CFG->directorypermissions = 02775;
$CFG->preventfilelocking = true;

Risks and Limitations

  • 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.
  • SOURCE --: rsync -ar --progress --partial /home/<user>/* root@198.51.100.42:/home/<user>/
  • 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.

A full list of changes between versions is available on the website.

Creating Moodle’s cron job

  • 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):
*/1 * * * * /usr/bin/php  /home/12mprove/public_html/admin/cli/cron.php >/dev/null

If you’re using php-fpm (to run multiple php versions on the same server), then you’ll want to specify the exact php version:

*/1 * * * * /usr/bin/php7.2  /home/12mprove/public_html/admin/cli/cron.php >/dev/null

(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.

  • php /home/<website>/public_html/admin/tool/replace/cli/replace.php --search="<old domain>" --replace="<new domain>"

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:

#!/bin/bash
## location: /home/example-db/db-backup/example-db-backup.sh
mysqldump --single-transaction -uexample-db -p[secret_password] example-db | gzip -c > /home/example-db/db-backup/example-db.sql.gz

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.”
  • https://serversforhackers.com/c/mysqldump-with-modern-mysql

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:

#!/bin/bash
## location: /home/[customer]/db-backup/[customer]-backup.sh
mysqldump --single-transaction -u[customer] -p [secret_password] [customer] | gzip -c > /home/[customer]/db-backup/[customer].sql.gz

Reinstall the SSL Certificates

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:

$CFG->divertallemailto = "youremail@yourdomain.com";
  • 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:
  • innodb_buffer_pool_size = 4294967296

(And then restart MySQL). See also https://dba.stackexchange.com/questions/124964/error-2013-hy000-lost-connection-to-mysql-server-during-query-while-load-of-my

Switching over to the new website.

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
  • -x = extract
  • -f - = read the archive from stdin

No temporary file is created.

Example:

ssh solin@destination 'tar -C /var/www/moodle-prd/moodledata -xf -'

Full Command for Streaming moodledata

Below is the production-ready command, including:

  • screen
  • progress meter
  • email notification
  • automatic extraction on DESTINATION

Replace paths accordingly.

Command (run on SOURCE)

ssh -A academy-demo@academy.adventureworks.example
screen -S stream-moodledata

tar -C /home/webroot/academy-demo/academy.adventureworks.example/moodledata -cf - . \
| pv \
| ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=5 solin@149.210.215.9 \
    'tar -C /var/www/moodle-prd/moodledata.20251025 -xf -' \
; echo "moodledata transfer finished on $(hostname) at $(date -Is)" \
| mail -s "moodledata stream done" onno@your-domain.com

Explanation

  • tar -C ... -cf - . Streams the moodledata contents only (not the dir itself).
  • pv Shows progress.
  • SSH options:
  • ServerAliveInterval=60 sends a keep-alive every minute
  • ServerAliveCountMax=5 abort if 5 keep-alives fail These prevent half-open SSH hangs during long transfers.
  • tar -C /target -xf - on DESTINATION Immediately unpacks the data into the target moodledata directory.
  • Email at the end Once the tar command finishes, a small message is piped into mail for notification.

Confirming Firewall Access

If streaming hangs right after pv starts output, SSH is not connecting.

You can test manually:

ssh solin@DESTINATION_IP 'echo ok'

If this times out, firewall access is missing.

Checklist After Transfer

  • Confirm files on DESTINATION:
ls -lh /var/www/moodle-prd/moodledata.20251025
  • Adjust permissions if needed:
chown -R www-data:www-data /var/www/moodle-prd/moodledata.20251025
  • Test Moodle access and course file delivery.

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.

Migrating from MySQL to PostgreSQL

Install PostgreSQL

Add the following packages:

apt-get install postgresql
apt-get install php8.1-pgsql
apt-get install pgloader

Please make sure to check the actual php version you’re using on your Moodle or Totara website.

Install pgloader

The version of pgloader that comes with Ubuntu 22.04 (through apt-get) did not work for me, it gave me an error for which I found no solution.

Instead, I compiled pgloader from source as explained here:

sudo apt install sbcl unzip libsqlite3-dev gawk curl make freetds-dev libzip-dev
curl -fsSLO https://github.com/dimitri/pgloader/archive/v3.6.10.tar.gz
tar -xvf  v3.6.10.tar.gz
cd pgloader-3.6.10/
make pgloader
mv ./build/bin/pgloader /usr/local/bin/

And finally check whether the installation worked:

root@localhost:/home/sandbox/pgloader-3.6.10# pgloader --version
pgloader version "3.6.7~devel"
compiled with SBCL 2.1.11.debian

Configure PostgreSQL

Connect to the database server:

sudo -u postgres psql

Set a password for the postgres user:

ALTER USER postgres PASSWORD '<password>';

Replace the <password> with the one you want. Then quit with:

\q

(From: Install PostgreSQL Linux)

Create Database

On the command prompt (so, not in the psql client):

sudo -u postgres createdb -E utf8 {dbname}

Make sure that MySQL is using:

default-authentication-plugin=mysql_native_password

(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) :

pgloader mysql://sandbox:passwd@127.0.0.1/sample_db pgsql://postgres:passwd@localhost/sampledb
  • Please note: this should be the following –
  • pgloader --with "prefetch rows = 1000" mysql://sandbox:passwd@127.0.0.1/sample_db pgsql://postgres:passwd@localhost/sampledb

To see if it worked, use the following command to list all tables:

\dt

Issue: Heap exhausted

2024-03-22T13:02:33.040001Z LOG pgloader version "3.6.7~devel"
2024-03-22T13:02:33.188004Z LOG Migrating from #<MYSQL-CONNECTION mysql://sandbox@127.0.0.1:3306/fabrikamlive {10072891C3}>
2024-03-22T13:02:33.188004Z LOG Migrating into #<PGSQL-CONNECTION pgsql://postgres@localhost:5432/fabrikamsandbox {10074545A3}>
Heap exhausted during garbage collection: 0 bytes available, 32 requested.
Gen  Boxed   Code    Raw  LgBox LgCode  LgRaw  Pin       Alloc     Waste        Trig      WP GCs Mem-age
fatal error encountered in SBCL pid 859879 tid 859888:
GC invariant lost, file "gencgc.c", line 488

Solved this particular issue by adding this:

--with "prefetch rows = 1000"

So:

pgloader --with "prefetch rows = 1000" mysql://sandbox:passwd@127.0.0.1/sample_db pgsql://postgres:passwd@localhost/sampledb

Issue: out of shared memory

I subsequently ran into:

ERROR Database error 53200: out of shared memory
HINT: You might need to increase max_locks_per_transaction.

It turns out that this is a PostgreSQL setting which can be adjusted here:

vim /etc/postgresql/14/main/postgresql.conf

I set it to:

max_locks_per_transaction = 128 

Restart the PostgreSQL server:

service postgresql restart

This seems to have worked:

                      fabrikamlive.mdl_workshopform_rubric_config          0          0                    11.144s
                                       fabrikamlive.mdl_workspace          0          0                    11.072s
                        fabrikamlive.mdl_workspace_member_request          0          0                    11.084s
------------------------------------------------------------------  ---------  ---------  ---------  --------------
                                           COPY Threads Completion          0          4                  28m1.160s
                                                    Create Indexes          0       4334               3h59m42.459s
                                            Index Build Completion          0       4334                     0.164s
                                                   Reset Sequences          0        915                     2.876s
                                                      Primary Keys          0        915                     0.792s
                                               Create Foreign Keys          0        148                     0.424s
                                                   Create Triggers          0          0                     0.000s
                                                   Set Search Path          0          1                     0.000s
                                                  Install Comments          0        747                     0.264s
------------------------------------------------------------------  ---------  ---------  ---------  --------------
                                                 Total import time             86727644    20.9 GB    4h27m48.138s

Issue 20240323

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.”

Dump PostgreSQL Database

sudo -u postgres pg_dump fabrikamsandbox > fabrikamsandbox.20240329.sql

Here’s the canonical command:

pg_dump -h SRC_HOST -U SRC_USER -d SRC_DB \
  --format=plain \
  --no-owner --no-acl \
  --encoding=UTF8 \
  --blobs \
  | gzip > /path/to/src_db.sql.gz

–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.

Restore PostgreSQL Database

sudo -u postgres psql fabrikamlive < fabrikamsandbox.20240329.sql
sudo -u postgres createdb -E utf8 fabrikamlive

Or, with a dump that has no owner and privileges/grant:

  • CREATE ROLE LOGIN PASSWORD '***';
  • CREATE DATABASE newdb OWNER app_user;
  • psql -h DST_HOST -U -d newdb -v ON_ERROR_STOP=1 -f /path/to/src_db.sql

20240408

mysqldump --single-transaction --compatible=ansi --default-character-set=utf8 -u root -p fabrikamlive > fabrikamsandbox.20240408.sql

https://gist.github.com/barseghyanartur/56876ab3acbd3d5d6ab7dcc477c29238: completely out of date, throws python errors

https://github.com/AnatolyUss/NMIG: throws syntax errors (probably wrong version of nodejs / npm)

https://pgdba.org/post/migration/ pg_chameleon

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:

chameleon create_replica_schema
chameleon add_source --config migration --source mysql
chameleon show_status --config migration

https://github.com/maxlapshin/mysql2postgres last updates from 4 years ago, uses Ruby gems. Tried to install, but was stranded with gem dependency errors.

https://stackoverflow.com/questions/92043/is-there-a-simple-tool-to-convert-mysql-to-postgresql-syntax

root@localhost:~# df -h
Filesystem             Size  Used Avail Use% Mounted on
tmpfs                  791M  1.7M  789M   1% /run
/dev/mapper/vg00-lv01  392G  234G  142G  63% /
tmpfs                  3.9G   32K  3.9G   1% /dev/shm
tmpfs                  5.0M     0  5.0M   0% /run/lock
/dev/sda1              488M  251M  201M  56% /boot
tmpfs                  791M  8.0K  791M   1% /run/user/0

More pg_loader

20240415

pgloader --with "prefetch rows = 1000" --with "drop schema" mysql://sandbox:passwd@127.0.0.1/sample_db pgsql://postgres:passwd@localhost/sampledb

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:

pgloader --with "prefetch rows = 1000" --with "data only" --cast "type tinyint  when (= precision 1) to smallint keep typemod" mysql://sandbox:passwd@127.0.0.1/fabrikamlive pgsql://postgres:passwd@localhost/fabrikamsandbox

In the end, I got the migration working by:

  • Having Totara create the database schema
  • Using pgloader to import the data
  • 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)
  • rebooting the server
  • turning off Apache: service apache2 stop

Potentially troublesome table: mdl_temp_rb_course_completions_v1

20240720

Preparation

vim /etc/postgresql/14/main/postgresql.conf

I set it to:

max_locks_per_transaction = 128

Restart the PostgreSQL server:

service postgresql restart

Steps

Did Live Fabrikam Foods migration by:

  • Having Totara create the database schema
  • Using pgloader to import the data
  • 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.:
  • psql -U fabrikamstaging -d fabrikam -h 127.0.0.1

Proper PostgreSQL Restore Procedure (and Why It’s Necessary)

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.

1. Dump on the source (local host)

sudo -u postgres pg_dump -d fabrikamlive \
  --format=plain --no-owner --no-acl --encoding=UTF8 --blobs \
| gzip > /backups/fabrikamlive.YYYYMMDD.sql.gz

Notes:

  • Uses peer auth via the local Unix socket (no password prompts).
  • Keep --blobs unless you are sure there are no large objects.

2. Transfer the dump

scp /backups/fabrikamlive.YYYYMMDD.sql.gz user@DST_HOST:/tmp/

3. Prepare the target (one role, one DB)

Run once on the target (psql as a superuser/dbadmin):

sudo -u postgres psql
CREATE ROLE app_user LOGIN PASSWORD 'REPLACE_ME';
CREATE DATABASE newdb OWNER app_user;

Optional hardening (safe for single-role apps):

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;

4. Restore

If the dump is gzipped:

gunzip -c /tmp/fabrikamlive.YYYYMMDD.sql.gz | psql \
  -h DST_HOST -U app_user -d newdb -v ON_ERROR_STOP=1

(You’ll be prompted for the password, but the process will continue smoothly upon entering it.)

If you placed the dump on the DB server itself and want to use the postgres OS user:

gunzip -c /tmp/fabrikamlive.YYYYMMDD.sql.gz | sudo -u postgres psql -d newdb -v ON_ERROR_STOP=1

5. Post-restore sanity checks

Run as app_user on newdb unless noted.

Show search path:

SHOW search_path;

Extensions present:

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:

ssh SRC_HOST "sudo -u postgres pg_dump -d fabrikamlive --format=plain --no-owner --no-acl --encoding=UTF8 --blobs | gzip" \
  > /tmp/fabrikamlive.YYYYMMDD.sql.gz

Then proceed with steps 3–4.

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:

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

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:

git checkout upstream/MOODLE_401_STABLE
git switch -c moodle41

Go to master branch:

git checkout master

(Bitbucket uses main by default nowadays.)

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.

It’s probably a better idea to use git submodules if you want to be able to upgrade 3rd party (“additional”) plugins through git as well. See the explanation here: https://docs.moodle.org/401/en/Git_for_Administrators#Maintaining_Git_submodules.

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$ 
  • Create a moodledata directory:
sysadmin@yourserver:/home/westwood$ sudo mkdir moodledata
  • 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:
  • Use wget to download Moodle: sudo wget [the link]
sysadmin@yourserver:/home/westwood$ sudo wget https://download.moodle.org/download.php/direct/stable33/moodle-latest-33.tgz
--2017-08-27 08:58:10--  https://download.moodle.org/download.php/direct/stable33/moodle-latest-33.tgz
Resolving download.moodle.org (download.moodle.org)... 2400:cb00:2048:1::6814:ef, 2400:cb00:2048:1::6814:1ef, 104.20.0.239, ...
Connecting to download.moodle.org (download.moodle.org)|2400:cb00:2048:1::6814:ef|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 44163286 (42M) [application/g-zip]
Saving to: 'moodle-latest-33.tgz'

moodle-latest-33.tgz                                 100%[======================================================================================================================>]  42.12M  39.8MB/s    in 1.1s    

2017-08-27 08:58:11 (39.8 MB/s) - 'moodle-latest-33.tgz' saved [44163286/44163286]

sysadmin@yourserver:/home/westwood$ 
  • Rename the public_html to public_html.bak:
sysadmin@yourserver:/home/westwood$ sudo mv public_html public_html.bak
  • Extract the moodle code base:
sysadmin@yourserver:/home/westwood$ sudo tar -xf moodle-latest-33.tgz
  • Rename to public_html:
sysadmin@yourserver:/home/westwood$ sudo mv moodle public_html
  • Make a copy of public_html/config-dist.php:
sudo cp public_html/config-dist.php public_html/config.php
  • 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>

Reload:

service apache2 reload

Step 3: Configure Moodle

Add this to config.php before the setup include:

$CFG->xsendfile = 'X-Sendfile';
$CFG->xsendfilealiases = array();
  • Default for standard Apache Moodle sites: use X-Sendfile and leave $CFG->xsendfilealiases = [];
  • If logs or an X-Sendfile probe show aliased paths such as /dataroot/..., add the required alias mapping for that site.

Step 4: Basic Apache test

Create:

xsendtest.php:
<?php
header("X-Sendfile: /home/USERNAME/moodledata/xsend-test.txt");
header("Content-Type: text/plain");
header("Content-Disposition: inline; filename=\"xsend-test.txt\"");
exit;

Create the file:

echo "hello xsendfile" > /home/USERNAME/moodledata/xsend-test.txt

Test:

curl -i https://domain/xsendtest.php

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:

wget -q -O /dev/null http://www.example.com/admin/cron.php

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:

https://bitbucket.org/exampleorg/serverbackupscripts/src/master/

This script is executed using a cron job started at different times for each vhost:

/usr/bin/php7.2 /home/backupvhost/scripts/backupvhost.php --config="/home/inm/public_html/config.php" >/dev/null

The script creates differential backups and sends the encrypted tar ball to:

 solin@scp.hidrive.strato.com:/users/solin/{$hostname}_server_backups/{$CFG->dbname}/{$CFG->dbname}.backup_day{$backupday}.tar.gz.gpg

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:
  • /home/backupvhost/scripts/backupvhost.php/home/backupvhost/backup/
  • 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:

#!/bin/bash
## location: /home/example-db/db-backup/example-db-backup.sh
mysqldump --single-transaction -uexample-db -p[secret_password] example-db | gzip -c > /home/example-db/db-backup/example-db.sql.gz

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:

#!/bin/bash
## location: /home/[customer]/db-backup/[customer]-backup.sh
mysqldump --single-transaction -u[customer] -p[secret_password] [customer] | gzip -c > /home/[customer]/db-backup/[customer].sql.gz

8. Add Monitoring

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:

mkdir /restore

2. Select The Backup Files

First login to https://kis.hosting-provider.de/.

Navigate to The Correct VPS

Now click on Product Admin:

  • Choose Virtual Server:
  • 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:

engineer@opsbox:~$ nslookup academy.civic-rights.example
Server:		127.0.0.53
Address:	127.0.0.53#53

Non-authoritative answer:
Name:	academy.civic-rights.example
Address: 198.51.100.42

Select The Source for The Backup

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.
  • Ziel-Verzeichnis (muss existieren): target directory (must exist)
  • 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:

$ tar -zcpf - folder | gpg -c --passphrase thesecrectpassphrase > folder.tar.gz.gpg

And don’t forget to skip .git from the actual backup (when including public_html)

There are also existing scripts:

https://wiki.archlinux.org/title/duplicity

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).

Notes from https://www.gnu.org/software/tar/manual/html_section/Incremental-Dumps.html and https://www.theurbanpenguin.com/incremental-backups-with-tar/:

  • 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).

This is explained quite well here: https://linuxconfig.org/how-to-create-incremental-and-differential-backups-with-tar

There seems to be a way to create incremental backups with mysqldump, but it looks cumbersome: https://www.guguweb.com/2020/01/30/mysql-incremental-backup-with-mysqldump-and-rdiff-backup/

Besides, most databases compress to a relatively small size so it doesn’t matter if they’re added 14 times in a row.

Differential Backups

20230605

We now have the following script in operation on the La Forge server (82.165.1.29):

https://bitbucket.org/exampleorg/serverbackupscripts/src/master/

This script is executed using a cron job started at different times for each vhost:

/usr/bin/php7.2 /home/backupvhost/scripts/backupvhost.php --config="/home/inm/public_html/config.php" >/dev/null

This script creates differential backup and sends the encrypted tar ball to:

 solin@scp.hidrive.strato.com:/users/solin/{$hostname}_server_backups/{$CFG->dbname}/{$CFG->dbname}.backup_day{$backupday}.tar.gz.gpg

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.)

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.