This was a live Moodle migration from TMD shared hosting / Softaculous to a raw Hostinger Ubuntu VPS. No control panel, no Docker layer, no one-click Moodle app. Just a direct move with the least amount of extra moving parts.
The goal was simple: rebuild the environment cleanly, move the full Moodle stack, and get the site working on the new VPS before final DNS cutover.
The biggest lesson: with Moodle, migrations break when you treat them like a basic website copy. Moodle only works when database, codebase, moodledata, permissions, and runtime versions all line up.
TL;DR Checklist
- Keep the new server on plain Ubuntu.
- Install a Moodle-compatible PHP version before doing anything else.
- Create the new MariaDB database and DB user.
- Pull over the database dump, Moodle code, and
moodledata. - Extract large archives directly into their final destination to avoid duplicate disk usage.
- Fix ownership and permissions.
- Clean up
config.phpso old paths do not override new ones. - Configure Nginx and cron.
- Purge caches through CLI.
- Test on the VPS before final DNS and SSL cutover.
Why We Avoided the One-Click Route
The new VPS was just Ubuntu 24.04 LTS. Hostinger offered:
- Docker Moodle
- Application Moodle
- control panels
- plain OS
We stayed with plain Ubuntu because it causes the least trouble for a migration like this.
Why that mattered:
- Moodle 4.0.6 is older and sensitive to PHP version compatibility
- one-click stacks can hide paths, versions, and service config
- Docker adds extra layers for volumes, permissions, and backups
- a control panel adds complexity we did not need for a single-site migration
For this move, plain Ubuntu gave the cleanest path.
Source Environment
The old Moodle installation details were:
- Moodle version: 4.0.6
- URL:
https://yourdomain.com - Moodle code path:
/home/youruser/public_html/yourdomain.com moodledatapath:/home/youruser/moodledata- DB type:
mariadb - DB host:
localhost - DB name:
your_db_name - DB user:
your_db_user - DB password: existing production DB password from
config.php - Table prefix:
mdl_
Target Environment
The new destination stack was:
- Hostinger VPS
- Ubuntu 24.04 LTS
- Nginx
- MariaDB
- PHP 8.0
- Moodle code path:
/var/www/yourdomain.com moodledatapath:/var/www/moodledata
The Golden Rule: Moodle Is a Multi-Layer System
A Moodle migration works only when these layers stay aligned:
- Codebase
- Database
moodledata- Server runtime
If one of those is off, you get things like:
- HTTP 500 / 503 errors
- broken redirects
- missing assets
- blank pages
- bad login/session behavior
- CLI failures
- file/media issues
Step 1: Build a Clean Compatible Stack First
Before moving anything, install the right stack on the VPS.
In this case, the critical decision was using PHP 8.0, not the Ubuntu default PHP version, because the source Moodle was 4.0.6.

apt update && apt upgrade -y
apt install -y software-properties-common ca-certificates lsb-release apt-transport-https curl wget unzip git nano
add-apt-repository -y ppa:ondrej/php
apt update
apt install -y nginx mariadb-server php8.0-fpm php8.0-cli php8.0-common php8.0-mysql php8.0-xml php8.0-gd php8.0-curl php8.0-zip php8.0-mbstring php8.0-intl php8.0-soap php8.0-bcmath php8.0-ldap php8.0-opcache php8.0-redis imagemagick graphviz aspell ghostscript
systemctl enable nginx mariadb php8.0-fpm
systemctl start nginx mariadb php8.0-fpm
Then verify the actual stack instead of assuming it worked:
php -v
nginx -v
mysql --version
systemctl is-active nginx
systemctl is-active mariadb
systemctl is-active php8.0-fpm
Step 2: Create the Final Paths Early
mkdir -p /var/www/yourdomain.com
mkdir -p /var/www/moodledata
chown -R www-data:www-data /var/www/moodledata
chmod -R 770 /var/www/moodledata
This avoids confusion later when you start restoring files.
Step 3: Create the New Database
Secure MariaDB, then create a clean database and DB user for the new server.
CREATE DATABASE moodle_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'moodle_user'@'localhost' IDENTIFIED BY 'STRONG_PASSWORD_HERE';
GRANT ALL PRIVILEGES ON moodle_db.* TO 'moodle_user'@'localhost';
FLUSH PRIVILEGES;
Test the DB login immediately:
mysql -u moodle_user -p -e "SHOW DATABASES;"
Do this before importing anything.
Step 4: Export the Three Things That Actually Matter
From the source server, pull over:
- database dump
- Moodle code
moodledata
On the TMD side, this was the direct export approach:
mysqldump -u OLD_DB_USER -p OLD_DB_NAME > ~/moodle-db.sql
cd /home/youruser/public_html
zip -r ~/moodle-files.zip yourdomain.com
cd /home/youruser
zip -r ~/moodledata.zip moodledata
Then transfer directly to the new VPS:
scp ~/moodle-db.sql ~/moodle-files.zip ~/moodledata.zip root@NEW_VPS_IP:/root/
For large moodledata, direct server-to-server transfer saves time and avoids downloading to your local machine.
Step 5: Restore Smarter, Not Just Faster
This migration hit a very real problem:
No space left on device
The VPS had a 100 GB disk, and moodledata.zip alone was 34 GB.
The mistake was:
- keep the zip in
/root - unzip a full copy into
/root/moodledata - then copy another full copy into
/var/www/moodledata
That temporarily created multiple copies of the same data and filled the disk.
This is exactly how you burn through space during a migration.
The wrong pattern
unzip moodledata.zip
cp -a /root/moodledata/. /var/www/moodledata/
The better pattern
Extract directly into the final destination:
cd /var/www
unzip -q /root/moodledata.zip -d /var/www/
chown -R www-data:www-data /var/www/moodledata
find /var/www/moodledata -type d -exec chmod 770 {} \;
find /var/www/moodledata -type f -exec chmod 660 {} \;
Then free space immediately:
rm -f /root/moodledata.zip
df -h
Same idea for code:
cd /var/www
unzip -q /root/moodle-files.zip -d /var/www/
chown -R www-data:www-data /var/www/yourdomain.com
find /var/www/yourdomain.com -type f -exec chmod 644 {} \;
find /var/www/yourdomain.com -type d -exec chmod 755 {} \;
Step 6: Import the Database

mysql -u moodle_user -p moodle_db < /root/moodle-db.sql
Do not move on until the DB import completes cleanly.
Step 7: Fix config.php Properly
This is where a lot of Moodle migrations quietly break.
The config file needs to reflect the new server paths and DB values:
$CFG->wwwroot = 'https://yourdomain.com';
$CFG->dataroot = '/var/www/moodledata';
$CFG->admin = 'admin';
$CFG->dbtype = 'mariadb';
$CFG->dblibrary = 'native';
$CFG->dbhost = 'localhost';
$CFG->dbname = 'moodle_db';
$CFG->dbuser = 'moodle_user';
$CFG->dbpass = 'STRONG_PASSWORD_HERE';
$CFG->prefix = 'mdl_';
$CFG->dboptions = array (
'dbpersist' => 0,
'dbport' => '',
'dbsocket' => '',
'dbcollation' => 'utf8mb4_general_ci',
);
$CFG->directorypermissions = 0777;
require_once(__DIR__ . '/lib/setup.php');
Critical real-world lesson
In this migration, config.php contained duplicate lines, including an old dataroot later in the file:
$CFG->dataroot = '/home/youruser/moodledata';
That old line was overriding the correct new path and causing Moodle CLI to fail with:
- dataroot not configured properly
- directory does not exist or is not accessible
So do not just edit the top of config.php. Read the whole file and remove stale duplicate values.
Step 8: Validate moodledata as the Web User
Do not trust that the folder looks right from root only. Test it as the web server user.
ls -ld /var/www/moodledata
ls -la /var/www/moodledata | head
sudo -u www-data ls /var/www/moodledata | head
If www-data cannot read it, Moodle will break even if the folder exists.
Step 9: Configure Nginx
server {
listen 80;
listen [::]:80;
server_name yourdomain.com www.yourdomain.com;
root /var/www/yourdomain.com;
index index.php index.html index.htm;
client_max_body_size 512M;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ [^/]\.php(/|$) {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.0-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
try_files $uri /index.php?$query_string;
expires 7d;
access_log off;
}
location = /favicon.ico {
log_not_found off;
access_log off;
}
location = /robots.txt {
allow all;
log_not_found off;
access_log off;
}
location ~* \.(htaccess|git|env|ini|log|sh|sql)$ {
deny all;
}
}
Enable the site and reload:
rm -f /etc/nginx/sites-enabled/default
ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/yourdomain.com
nginx -t
systemctl reload nginx
Step 10: Configure Cron
crontab -l > /tmp/currentcron 2>/dev/null || true
echo "*/1 * * * * /usr/bin/php8.0 /var/www/yourdomain.com/admin/cli/cron.php >/dev/null 2>&1" >> /tmp/currentcron
crontab /tmp/currentcron
rm -f /tmp/currentcron
crontab -l
Step 11: Use CLI to Validate the Install
This is one of the safest sanity checks after restoring the site.
php /var/www/yourdomain.com/admin/cli/purge_caches.php
If this fails, stop and fix the environment before browsing around the site.
In this migration, that command helped catch the bad overridden dataroot path.
The Redirect Gotcha: Why We Saw a 303 Then a Broken Site
Server logs showed the new VPS was being hit and returning:
303
That pointed to a redirect issue.
The reason was simple:
config.phphadhttps://yourdomain.com- Nginx was only set up on port 80
- SSL was not fully configured yet
So Moodle redirected HTTP to HTTPS, but HTTPS was not ready.
For testing, there are two clean options:
- temporarily use
http://in$CFG->wwwroot - finish SSL first, then keep
https://
If you are mid-migration and just trying to validate the build privately, temporary HTTP is often faster.
Biggest Migration Problems We Hit
1) Disk exhaustion during restore
Root cause:
- storing zip
- storing extracted source
- storing copied destination
Fix:
- extract directly into final destination
- delete archives as soon as safe
2) config.php had duplicate settings
Root cause:
- old values from the source server remained lower in the file
- PHP used the last values, not the first edited ones
Fix:
- read the whole file
- remove duplicate
wwwroot,dataroot, and related settings
3) Moodle CLI reported bad dataroot
Root cause:
- old dataroot path still overriding the new one
Fix:
- clean
config.php - confirm
www-datacan actually read/var/www/moodledata
4) Browser tests were misleading
At one point, the browser showed a 503 page that looked like the old host. The fix was to confirm the request path using Nginx logs instead of guessing.
Use this:
tail -n 50 /var/log/nginx/error.log
tail -n 50 /var/log/nginx/access.log
That tells you whether your browser is hitting the new VPS or not.
Zero-Drama Cutover Advice
For a live Moodle, this is the safer move:
- build the new server privately
- validate DB, code,
moodledata, permissions, and cron - test with hosts-file override before public DNS cutover
- issue SSL
- only then point public DNS
That keeps the risky debugging away from the live traffic path.
Short Command Reference
Database dump
mysqldump -u USER -p DBNAME > moodle-db.sql
Direct transfer
scp ~/moodle-db.sql ~/moodle-files.zip ~/moodledata.zip root@SERVER_IP:/root/
Restore code
cd /var/www
unzip -q /root/moodle-files.zip -d /var/www/
Restore moodledata
cd /var/www
unzip -q /root/moodledata.zip -d /var/www/
Fix ownership
chown -R www-data:www-data /var/www/yourdomain.com
chown -R www-data:www-data /var/www/moodledata
Fix permissions
find /var/www/yourdomain.com -type f -exec chmod 644 {} \;
find /var/www/yourdomain.com -type d -exec chmod 755 {} \;
find /var/www/moodledata -type d -exec chmod 770 {} \;
find /var/www/moodledata -type f -exec chmod 660 {} \;
Purge caches
php /var/www/yourdomain.com/admin/cli/purge_caches.php
Featured Snippet Answers
What breaks most Moodle VPS migrations?
The most common failure points are wrong PHP version, stale config.php paths, bad moodledata permissions, and trying to restore large moodledata archives in a way that duplicates disk usage.
What is the safest way to restore moodledata on a smaller VPS disk?
Extract it directly into the final destination instead of unzipping to one location and copying it again.
Why can Moodle still fail after config.php looks correct?
Because duplicate values lower in the file can override the values you just edited. Read the entire file, not just the top.
Final Take
This move was not difficult because Moodle is complex. It was difficult because infrastructure details matter, and Moodle exposes every mismatch fast.
That is exactly why the best migration strategy is not speed. It is sequencing.
Correct PHP version first.
Correct paths second.
Correct data restore method third.
Correct config cleanup fourth.
Then verify through CLI before browser testing.
That order saves hours.
If you want, I can also turn this into a second short checklist version for your /tutorials/ section.
Keep the Thread Going
- Service path: Website Migration Toronto
- Related read: Moodle Migration Playbook for Production Stability
- Proof point: Aerconic
- Ready to scope your own version? Start a project
