Pre-flight check.

Before proceeding, please double check that you have proper answers to the following questions at hand. Triple-check with your 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

You must be very mindful about not modifying the SOURCE website and configuration. In a worst-case scenario, you really want to be able to quickly rollback to the SOURCE website.

To easily differentiate between the source and the destination server, you could prepend the word SOURCE or DESTINATION to the prompt like this:

PS1="SOURCE -- \w : "

This change is temporary and will reset to the default value for any new sessions. As long as you don’t take intentional and specific steps to save it permanently, you don’t have to worry about it.

Throughout this guide we will stick to the SOURCE and DESTINATION nomenclature.

Warning: You are operating on two production servers that contain multiple Moodle installs each. Any downtime for any reason will affect all of them!

You do not need to restart the Apache service at any point during this process, you can use:

  • apachectl configtest to test whether the config files are properly written.

service apache2 reload to process changes to the configuration.

Overview of the typical SOURCE configuration.

Any of our Moodle installs has a few moving parts you must be familiar with:

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

Migrating the Moodle install

Recreating the folder structure on the destination server

First of all, we need to create the user and folder structure. Let’s get the uid of the SOURCE user:

SOURCE –: /tmp grep <user name> /etc/passwd

<user name>:x:1025:1025:<user name>,,,:/home/<user name>:/bin/bash

As we can see, the uid is 1025. We will create a user with this same uid on the new system.

DESTINATION –: /tmp useradd -u 1025 <user name>

After creating it we are going to switch users, create the folder structure and assign the proper permissions so we can transfer files later.

DESTINATION –: su <user name>

DESTINATION –: cd /home

DESTINATION –: chown -R <user name> <user name>

DESTINATION –: chgrp -R <user name> <user name>/

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.

Transferring the SOURCE data to the DESTINATION server.

Transferring and restoring the database.

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:

SOURCE –: grep db /home/<website>/public_html/config.php

$CFG->dbtype = 'mysqli';
$CFG->dblibrary = 'native';
$CFG->dbhost = 'localhost';
$CFG->dbname = 'the value we need';
$CFG->dbuser = '<website>';
$CFG->dbpass = <password>;

And to back it up we use the following command:

SOURCE –: mysqldump –single-transaction -u root -p[mysql_root_password] [database_name] > backup-website.sql

Transferring the user folder to the new server.

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@203.0.113.10:/home/<user>/

This will take a while, and Rsync will keep us informed of the progress.

Manually fixing permissions, if the user has a different UID.

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.

sudo chown -R root /home/<website>/public_html
sudo chmod -R 0755 /home/<website>/public_html
sudo chown -R www-data:www-data /home/<website>/moodledata
sudo chmod -R 0750 /home/<website>/moodledata

Database

To restore the database, first we need to create the user for Moodle and give it appropriate permissions. To do so, we obtain the values Moodle is using from SOURCE:/home/<website>/public_html/config.php:

SOURCE –: grep db /home/<website>/public_html/config.php

$CFG->dbtype = 'mysqli';
$CFG->dblibrary = 'native';
$CFG->dbhost = 'localhost';
$CFG->dbname = '<dbname>';
$CFG->dbuser = '<dbuser>';
$CFG->dbpass = <dbpassword>;

Creating the Moodle user and database.

We need to log in into the DESTINATION Mysql database. To do so, we run the following command.

DESTINATION –: mysql -uroot -p

Once we are in, we need to run the following queries, replaced by the data we have obtained in the previous step.

CREATE DATABASE dbname;
CREATE USER dbuser@'localhost' IDENTIFIED BY
dbpassword;
GRANT ALL PRIVILEGES ON dbname TO
dbuser'@'localhost';
FLUSH PRIVILEGES;

Restoring the database contents.

To restore Moodle’s data, we need to import the backup we created at the beginning of this document into the database we just created.

DESTINATION –: mysq -u<dbname> -p dbpassword –database < /path/to/backup-website.sql

Consider using screen to detach the process from the terminal, if the database is very large and the import will take several hours.

Check MySQL settings

MariaDB has innodb_adaptive_hash_index turned off, by default. We absolutely need this turned on for these large and complex involving the lookup of quiz attempts etc.. Change this to ‘ON’ in your version of ‘my.cnf’. (For example-host on the VPS provider, this file is: /etc/mysql/mariadb.conf.d/50-server.cnf)

Please note: I still couldn’t get MariaDB to perform adequately. Course loading times were 10 – 50 times what they were on the exact same server using MySQL.

And finally, make sure the default collation is set to utf8mb4_unicode_ci.

For Totara: Grant Permissions to Create Tables & Indexes

If you’re migrating a Totara installation, please make sure the database user has adequate permissions to create caching tables and indexes. (For the report builder.)

Adjusting Apache’s configuration.

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.

Special case: Apache’s source and destination differ.

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 our 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:

  1. Authentication issue when using ‘traditional’ native MySQL native password

  2. 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 hortest@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 our case.)

There will still be a notice:

Notice: Undefined index: engine in /home/exampletenant2/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

Our data center (the 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/exampledbuser/db-backup/exampledbuser-backup.sh

mysqldump –single-transaction -uexampledbuser -p[secret_password] exampledbuser | gzip -c > /home/exampledbuser/db-backup/exampledbuser.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.”

lms.example.com/c/mysqldump-with-modern-mysql

Steps to get the Bash script working

  1. Create a db-backup directory in the home directory.

  2. Use vim or another text editor to create the [customer]-backup.sh script inside the db-backup directory.

  3. Set the owner to the customer: chown -R [customer]:[customer] /home/[customer]/db-backup

  4. Set the permissions to 770: chmod -R 770 db-backup

  5. Give the database user (exampledbuser in our example above) the proper permissions to user mysqldump: PROCESS (‘Manage processes’ in Webmin)

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

    1. If you run into a disk quota issue: set the quota to unlimited for both the user and the group.

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

  1. 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 lms.example.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 = "admin@lms.example.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 your 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 example-host, we had to switch to port 465 and ssl to get it working again, after migrating their websites to our 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 lms.example.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

Editor’s note: This section is adapted from internal engineering notes and kept because it contains operational detail that may still be useful in the field.

Field Notes

  • Add section on how to install Webmin & Virtualmin

  • Add section on turning off

  • Add section on monitoring through Webmin & Munin

  • Add section on dev / test version of Moodle, which should always be done first before the ‘live’ migration

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 admin@lms.example.com
    screen -S stream-moodledata
    tar -C /home/webroot/lerenicm/lms.example.com/moodledata -cf - . \

    | pv \

    | ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=5 solin@198.51.100.11 \

    'tar -C /var/www/moodle-prd/moodledata.20251025 -xf -' \

    ; echo “moodledata transfer finished on $(hostname) at $(date -Is)” \

    | mail -s "moodledata stream done" admin@lms.example.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

    1. Confirm files on DESTINATION:

    ls -lh /var/www/moodle-prd/moodledata.20251025

    1. Adjust permissions if needed:

    chown -R www-data:www-data
    /var/www/moodle-prd/moodledata.20251025
    1. Test Moodle access and course file delivery.

    Solin handles Moodle migrations, cutovers, DNS changes, and post-migration validation for self-hosted and managed platforms. Need help? Contact us.

    Contact us