This Moodle VPS migration moved a live Moodle 4.0.6 site from TMD shared hosting / Softaculous to a raw Ubuntu VPS. Hostinger was the destination in this project, but the migration sequence applies to comparable unmanaged VPS providers. This is an independent technical example, not a hosting recommendation, sales offer, or partnership.
The commands below use placeholders, not real credentials. Always verify the current Moodle/PHP compatibility matrix, package names, and host-specific paths before applying them to a production LMS. Moodle publishes current release requirements and PHP compatibility guidance; treat this article as a migration pattern, not a universal package recipe.
The goal was to rebuild the environment cleanly, move the full Moodle stack, and verify the new VPS before final DNS cutover. If you need to inventory URLs, dependencies, and rollback requirements first, use the Migration Planner. For a managed migration path, see Website Migration Toronto.
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.
Who This Guide Is For
This guide is for Moodle administrators, developers, and technical teams moving an existing LMS from shared hosting to an unmanaged Ubuntu VPS. You should be comfortable with SSH, Nginx, MariaDB, PHP-FPM, DNS, backups, and a tested rollback plan; it is not a beginner server-administration tutorial.
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: placeholder copied securely during migration; never paste the real value into notes or docs
- Table prefix:
mdl_
Target Environment
The destination stack for this historical migration 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. Newer Moodle releases require newer PHP ranges, so confirm the active version before copying any package command.

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 = 02770;
require_once(__DIR__ . '/lib/setup.php');
Use group ownership that lets the web server write where Moodle needs it, and avoid world-writable permissions in production. If a migration temporarily needs broader access, narrow it again before opening the site to users.
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 can be useful only behind tight access controls. Do not open a production Moodle to users until HTTPS and the final $CFG->wwwroot are correct.
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.
Post-Cutover SEO Verification
Once public DNS and SSL are live, verify that the migration did not create a search or crawl regression:
- confirm the preferred HTTPS hostname resolves in one clean path with no redirect loop
- check public landing pages, course pages intended for indexing,
robots.txt, and any XML sitemap the site exposes - confirm canonical tags still use the final production hostname and protocol
- test representative old URLs and preserve redirects for any URL that changed
- inspect page source and browser requests for assets still pointing to the old host
- review Google Search Console for crawl errors, unexpected exclusions, and sitemap processing after cutover
- annotate the migration date in analytics and compare organic landing-page traffic before and after the move
Private courses, login pages, user profiles, and administrative routes should remain excluded according to the site's existing privacy and indexing policy. If the cutover changes canonical behavior, crawl access, redirects, or indexed URL coverage, a Technical SEO Audit can separate migration defects from normal recrawl lag.
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.
Keep the Thread Going
- Service path: Website Migration Toronto
- Planning tool: Website Migration Planner
- Post-cutover audit: Technical SEO Audit Toronto
- Related read: Moodle Migration Playbook for Production Stability
- Related public work: Aerconic
- Ready to scope your own version? Start a project




