Skip to main content

💾 Persistence & Volumes

Moodle stores state in three different places. Choosing which ones to persist determines how your backups work and how painful upgrades are.

What each path contains​

Path inside the containerContentsShould you persist it?
/var/www/htmlMoodle PHP code + config.php + installed plugins/themesDepends — see below
/var/www/moodledataUser-generated data: file uploads, sessions, cache, question attachments, backupsYes, always
Database volume (Postgres / MariaDB)Courses, users, grades, config, everything metadataYes, always
services:
postgres:
image: postgres:alpine
volumes:
- postgres:/var/lib/postgresql # (1)

moodle:
image: ruseler/alpine-moodle
volumes:
- moodledata:/var/www/moodledata # (2)
- moodlehtml:/var/www/html # (3)

volumes:
postgres:
moodledata:
moodlehtml:
  1. Database files. Back up with pg_dump. See the note on PostgreSQL 18+ below.
  2. Uploads, sessions, file storage. Back up with a cold copy or tar.
  3. Moodle code, plugins, themes and config.php. See the upgrade tradeoff below.

The moodlehtml tradeoff​

Mounting /var/www/html as a named volume preserves installed plugins, custom themes, and config.php between container restarts. The cost is that upgrading Moodle by changing the image tag alone does not work, because the old code is kept on the volume (#102, #103).

You have two patterns:

Pros: plugins and themes survive restarts, config.php keeps customisations.

Cons: to upgrade Moodle you need to remove the moodlehtml volume, start the new image, then re-apply plugins/themes. Always back up first.

docker compose down
docker volume rm <project>_moodlehtml
docker compose pull
docker compose up -d
Future improvement

#103 tracks a finer-grained approach that would persist only config.php, themes and modules. Until that lands, pick the pattern that matches your risk tolerance.

PostgreSQL 18+ volume path​

Since PostgreSQL 18, the official postgres image expects the named volume at /var/lib/postgresql, not /var/lib/postgresql/data (#133). If you pull postgres:alpine today you are on 18+.

# Correct for postgres:18+ (and postgres:alpine today)
volumes:
- postgres:/var/lib/postgresql

If you already have an existing volume created with the old path, follow the PGDATA migration notes on Docker Hub before switching. Mounting the old path on a new major version can silently overwrite your data.

Permissions​

The container runs as the non-privileged nobody user (UID 65534). Named Docker volumes get the right ownership automatically. Bind mounts do not — if you mount a host directory, make sure it is writable by UID 65534:

sudo chown -R 65534:65534 ./moodledata ./moodlehtml

See #2, #6, #117 for historical permission problems.

Backups​

Minimal backup plan for a PostgreSQL + named-volumes deployment:

# 1. Database
docker compose exec -T postgres pg_dump -U moodle moodle | gzip > moodle-db.sql.gz

# 2. Moodle data (uploads, sessions, file storage)
docker run --rm \
-v <project>_moodledata:/data:ro \
-v "$PWD":/backup \
alpine tar czf /backup/moodledata.tar.gz -C /data .

# 3. (Optional) Moodle code + plugins + config.php
docker run --rm \
-v <project>_moodlehtml:/html:ro \
-v "$PWD":/backup \
alpine tar czf /backup/moodlehtml.tar.gz -C /html .

Restore is the reverse: recreate the volumes, extract the tarballs back into them, and run pg_restore.

tip

Put the site into maintenance mode before taking a cold backup:

docker compose exec moodle php admin/cli/maintenance.php --enable
# ... run backup ...
docker compose exec moodle php admin/cli/maintenance.php --disable

This documentation is a fork of erseco/alpine-moodle. The upstream documentation is available here.