VPS Snaps

How to back up and restore a Moodle site

A Moodle site backup has three parts: a dump of the database, a copy of the moodledata directory, and the Moodle code with its config.php. Dump the database first, then archive moodledata without its cache and temp folders. To restore, load all three on the new server, update wwwroot and dataroot in config.php, purge the caches and run the search and replace tool if the URL changed. Course backups (.mbz files) do not replace any of this.

9 min readUpdated Checked against official documentation

The three parts of a Moodle site

This guide uses Moodle 5.2 in /var/www/moodle, data in /var/moodledata, a database named moodle and a web server running as www-data. Read your own values from config.php:

Terminal
grep -E 'CFG->(dbtype|dbhost|dbname|dbuser|prefix|wwwroot|dataroot)' /var/www/moodle/config.php
PartWhereBack it up?
Database$CFG->dbname, PostgreSQL or MySQL/MariaDBYes. Users, courses, grades, settings.
Uploaded filesmoodledata/filedirYes. Files are named by a hash of their content, so they are useless without the database.
Encryption keymoodledata/secret, or $CFG->secretdatarootYes. Without it, encrypted settings cannot be read.
Language packs, cache configmoodledata/lang, moodledata/mucYes.
Caches, sessions, temp files, trashmoodledata/cache, localcache, sessions, temp, trashdirNo. Moodle's migration guide says they need not be copied.
Code, plugins, config.php/var/www/moodleYes. Custom and third-party plugins cannot always be downloaded again.

Since Moodle 5.1, the web-accessible files live in a public folder and the web server's document root must point at it. config.php and admin/cli stay one level up, in the Moodle root. Plugin CLI scripts moved under public.

config.php holds the database password and moodledata holds the encryption key. Keep the backups in private storage and encrypt them.

Course backups are not a site backup

Moodle can save each course as a .mbz file, and Site administration > Courses > Backups > Automated backup setup does it on a schedule. Moodle's documentation says course backups "should never be used as a primary backup system". Each file holds one course; the code, plugins, site settings and anything outside courses are not in it. Moodle 5.2's defaults make the gap wider: automated backups are disabled, keep only 1 backup per course, skip hidden courses and courses unchanged for 30 days, and are stored in the course backup filearea, which is inside moodledata on the same server.

Use course backups to copy or reuse courses. If you keep automated ones, a site backup carries them as well, so watch how much they add to moodledata.

Maintenance mode

Moodle's migration guide starts with maintenance mode, to stop new data arriving. Run Moodle's CLI scripts as the web server user, so files they create in moodledata stay writable for it:

Terminal
sudo -u www-data /usr/bin/php /var/www/moodle/admin/cli/maintenance.php --enable

CLI maintenance mode blocks all web access, administrators included. CLI scripts keep working, except admin/cli/cron.php. It works by writing climaintenance.html into moodledata and setting maintenance_enabled in the database, so a backup taken during it restores into maintenance mode. --disable clears both. Use it for a migration. For nightly backups on a busy site, you can instead dump the database first and copy moodledata straight after; a file added in between is then stored but unused, which is harmless.

Dump the database

Terminal
install -d -m 700 /var/backups/moodle

On PostgreSQL, a custom-format dump is compressed and restores with pg_restore. pg_dump reads from one snapshot, so the dump is consistent while the site runs:

Terminal
sudo -u postgres pg_dump -Fc moodle > /var/backups/moodle/moodle-db-$(date +%F).dump
Terminal
pg_restore --list /var/backups/moodle/moodle-db-2026-10-04.dump | head

--list prints the archive's table of contents, a quick sign the file is a readable pg_dump archive; the exit status of pg_dump itself is what tells you the dump finished. The pg_dump guide covers the other formats.

On MySQL or MariaDB, put the credentials in an option file so the password stays out of ps, then dump:

/etc/mysql/moodle-backup.cnf
[client]
user=moodleuser
password="your-password-here"
host=localhost
Terminal
chmod 600 /etc/mysql/moodle-backup.cnf
Terminal
mysqldump --defaults-extra-file=/etc/mysql/moodle-backup.cnf --single-transaction --no-tablespaces --default-character-set=utf8mb4 moodle | gzip > /var/backups/moodle/moodle-db-$(date +%F).sql.gz
  • --single-transaction dumps InnoDB tables from one consistent snapshot. It also means mysqldump does not need LOCK TABLES, which the grants in Moodle's MySQL guide leave out.
  • --no-tablespaces avoids the PROCESS privilege, also missing from those grants.
  • --default-character-set=utf8mb4 matches the character set Moodle's guide creates the database with, so emoji and other 4-byte characters survive.
  • On MariaDB 11 and later the program is also called mariadb-dump.

Archive moodledata and the code

Archive moodledata after the dump, leaving out the folders Moodle's migration guide lists as unnecessary. Exclude patterns match the stored names and must come before the directory:

Terminal
tar -czf /var/backups/moodle/moodledata-$(date +%F).tar.gz --exclude='moodledata/cache' --exclude='moodledata/localcache' --exclude='moodledata/sessions' --exclude='moodledata/temp' --exclude='moodledata/trashdir' -C /var moodledata
Terminal
tar -czf /var/backups/moodle/moodle-code-$(date +%F).tar.gz -C /var/www moodle

If config.php sets $CFG->secretdataroot, $CFG->tempdir or $CFG->cachedir elsewhere, add the secret directory to the archive; the others can be skipped. A large filedir changes little from day to day, so rsync or restic copy it faster than a nightly full archive.

Restore on a new server

Install the web server, the PHP version and extensions your Moodle release requires, and the database server. Create an empty database and user as Moodle's guides do. PostgreSQL:

PostgreSQL prompt (sudo -u postgres psql)
CREATE USER moodleuser WITH PASSWORD 'yourpassword';
CREATE DATABASE moodle WITH OWNER moodleuser;
Terminal
sudo -u postgres pg_restore --dbname=moodle --no-owner --role=moodleuser --exit-on-error < /var/backups/moodle/moodle-db-2026-10-04.dump

--no-owner drops the original ownership commands and --role creates every object as moodleuser, so the restore works even if the old owner had a different name. Reading from < lets root's shell open the private backup file. On MySQL or MariaDB, create the database with Moodle's settings, recreate the option file from above on this server, and load the dump:

MySQL / MariaDB prompt
CREATE DATABASE moodle DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER moodleuser@localhost IDENTIFIED BY 'yourpassword';
GRANT SELECT,INSERT,UPDATE,DELETE,CREATE,CREATE TEMPORARY TABLES,DROP,INDEX,ALTER ON moodle.* TO moodleuser@localhost;
Terminal
gunzip -c /var/backups/moodle/moodle-db-2026-10-04.sql.gz | mysql --defaults-extra-file=/etc/mysql/moodle-backup.cnf moodle

Extract moodledata and the code, then give moodledata to the web server user:

Terminal
tar -xzf /var/backups/moodle/moodledata-2026-10-04.tar.gz -C /var
Terminal
tar -xzf /var/backups/moodle/moodle-code-2026-10-04.tar.gz -C /var/www
Terminal
chown -R www-data:www-data /var/moodledata

The web server needs read and write access to moodledata and read access to the code; Moodle's install guide leaves the code owned by root. Edit config.php for this server. wwwroot takes no trailing slash:

/var/www/moodle/config.php
$CFG->dbhost   = 'localhost';
$CFG->dbname   = 'moodle';
$CFG->dbuser   = 'moodleuser';
$CFG->dbpass   = 'yourpassword';
$CFG->wwwroot  = 'https://moodle.example.com';
$CFG->dataroot = '/var/moodledata';

Point the web server's document root at /var/www/moodle/public (Moodle 5.1 and later). If the address changed, links stored in course content still point at the old one. Moodle's search and replace tool fixes them; its own help says changes cannot be reverted, so run it on a site you have just backed up. --shorten is needed when the new string is longer than the old:

Terminal
sudo -u www-data /usr/bin/php /var/www/moodle/public/admin/tool/replace/cli/replace.php --search=//old.example.com --replace=//moodle.example.com --shorten --non-interactive

On Moodle 5.0 and earlier, drop public/ from that path. Then clear the caches built on the old server and take the site out of maintenance mode:

Terminal
sudo -u www-data /usr/bin/php /var/www/moodle/admin/cli/purge_caches.php
Terminal
sudo -u www-data /usr/bin/php /var/www/moodle/admin/cli/maintenance.php --disable

Finally install Moodle's cron for the web server user with crontab -u www-data -e, running every minute:

crontab -u www-data -e
* * * * * /usr/bin/php /var/www/moodle/admin/cli/cron.php >/dev/null

Upgrades versus restores

The database records the Moodle version that last upgraded it, and Moodle refuses to run older code against it. Restore the code from the same backup set as the database. Newer code is allowed: Moodle then runs an upgrade, which is how you move an old backup onto a current release. Moodle 5.2 only upgrades from 4.4 or later, so older sites need an intermediate step.

An upgrade cannot be undone in place. Moodle's upgrade guide says to back up the code, moodledata and the database first; to go back, you restore all three from that moment. Practice the upgrade on a restored copy, where admin/cli/upgrade.php --non-interactive runs it from the command line.

Verify and automate

Terminal
sudo -u www-data /usr/bin/php /var/www/moodle/admin/cli/checks.php

That runs Moodle's status checks and reports the ones that fail; -v shows all of them. Then fetch the login page and expect 200, log in, open a course and download a file from it, a quick check that moodledata matches the database:

Terminal
curl -sS -o /dev/null -w '%{http_code}\n' https://moodle.example.com/login/index.php

For nightly backups on PostgreSQL, this script gives each file its real name only after the command that wrote it succeeded, and treats tar's exit code 1 (a file changed while being read) as success:

/usr/local/bin/moodle-backup.sh
#!/bin/bash
set -euo pipefail

BACKUP_DIR="/var/backups/moodle"
KEEP_DAYS=14
STAMP="$(date +%Y-%m-%d_%H%M)"
DB="$BACKUP_DIR/moodle-db-$STAMP.dump"
DATA="$BACKUP_DIR/moodledata-$STAMP.tar.gz"
CODE="$BACKUP_DIR/moodle-code-$STAMP.tar.gz"

mkdir -p "$BACKUP_DIR"
trap 'rm -f "$DB.partial" "$DATA.partial" "$CODE.partial"' EXIT

sudo -u postgres pg_dump -Fc moodle > "$DB.partial"
pg_restore --list "$DB.partial" > /dev/null
mv "$DB.partial" "$DB"

tar -czf "$DATA.partial" --exclude='moodledata/cache' --exclude='moodledata/localcache' \
  --exclude='moodledata/sessions' --exclude='moodledata/temp' --exclude='moodledata/trashdir' \
  -C /var moodledata || [ $? -eq 1 ]
mv "$DATA.partial" "$DATA"

tar -czf "$CODE.partial" -C /var/www moodle
mv "$CODE.partial" "$CODE"

find "$BACKUP_DIR" -type f -name 'moodle*' -mtime +"$KEEP_DAYS" -delete
/etc/cron.d/moodle-backup
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

30 2 * * * root flock -n /run/lock/moodle-backup.lock /usr/local/bin/moodle-backup.sh >> /var/log/moodle-backup.log 2>&1

Make it executable with chmod 755, copy the results off the server, and test a restore on a spare machine before term starts.

Common errors

ErrorFix
Error: The code you are using is older than the version recorded in the database.Restore the code from the same backup set as the database, or newer code.
Error: Database connection failedCheck dbtype, dbhost, dbname, dbuser and dbpass in config.php, and the user's grants.
Fatal error: $CFG->dataroot is not configured properly, directory does not exist or is not accessible! Exiting.Set $CFG->dataroot to the restored path.
Fatal error: $CFG->dataroot is not writable, admin has to fix directory permissions! Exiting.chown -R www-data:www-data /var/moodledata.
Incorrect access detected, this server may be accessed only through "..." address, sorry.$CFG->wwwroot does not match the address you used.
Every page shows the maintenance messageThe backup was taken in maintenance mode. Run maintenance.php --disable.
The replacement is longer than the original and shortening is not allowed; cannot continue.Add --shorten to replace.php.

Moving to another provider? See moving a server to a new provider. Restoring a PostgreSQL dump in more depth: restoring a PostgreSQL dump.

Frequently asked questions

Are Moodle course backups enough?
No. Moodle's documentation says course backups should never be your primary backup. They hold one course each, without the code, plugins or site settings, and by default they are stored inside moodledata on the same server.
Which moodledata folders can I skip?
Moodle's migration guide lists cache, localcache, sessions, temp and trashdir as unnecessary. Keep filedir, lang, muc and secret.
Do I need maintenance mode to back up Moodle?
For a migration, yes; Moodle's guide starts with it. For nightly backups, pg_dump and mysqldump with --single-transaction are consistent on their own; dump the database before copying moodledata.
How do I change the Moodle URL after a restore?
Set $CFG->wwwroot in config.php, then run admin/tool/replace/cli/replace.php (under public/ on Moodle 5.1 and later) with the old and new addresses, and purge the caches.

How this was checked

Commands, limits and prices were checked against these official pages, on October 4, 2026: