Why Moodle's Automated Backups Quietly Ate 6 TB — and the One Setting That Turns Deletion Back On

80,000 .mbz files, 268 backups per course, a root volume at 96% — because the retention math was configured so deletion could never fire. Here's how to confirm the same bug on your site and fix it without touching the database.

Moodle's automated-backup cleanup never fires when backup_auto_min_kept exceeds the number of backups a course actually has: get_backups_to_delete() computes 268 ≤ 400 and returns nothing, every run, forever. The fix: set backup_auto_delete_days to 0 via admin/cli/cfg.php — min_kept is only consulted when delete_days is positive, so max_kept retention immediately takes over.

The alert said the root volume was at 96%. When we attributed the ~6 TB in use, ~5.9 TB of it — 99% — was automated course backups: roughly 80,000 .mbz files across ~300 courses. That's about 268 backups per course, with some large courses writing a fresh ~2 GB backup every single night and never deleting one.

If you got here by googling "moodledata folder huge," "moodle not deleting old automated backups," or "moodle backup_auto_max_kept not working" — this is almost certainly your problem, and the fix is genuinely one setting. But the reason it happens is worth understanding, because the same misconfiguration will quietly refill your disk if you only treat the symptom.

Nothing was broken in the usual sense. Cron was running. The backup task completed successfully every night. Moodle™ was doing exactly what its settings told it to do — and the settings told it that deletion could mathematically never fire.

Root cause

Why Moodle stops deleting old automated backups

Automated backup retention lives in backup/util/helper/backup_cron_helper.class.php, in a function called get_backups_to_delete(). It applies three settings, and their interaction is where sites get hurt:

  • backup_auto_max_kept — keep at most this many backups per course.
  • backup_auto_delete_days — also delete backups older than this many days…
  • backup_auto_min_kept — …but never drop below this floor.

The floor is the trap. On this site, backup_auto_min_kept was set to 400 — higher than the ~268 backups any course actually had. So every night, the "delete the excess over the minimum" step computed 268 <= 400 and returned "nothing to delete." Not an error. Not a warning. Just an empty deletion list, on every run, forever.

The retention math that never deletes 0 100 200 300 400 backups the course actually has: 268 backup_auto_min_kept = 400 deletion only fires past the floor every run: 268 ≤ 400 → “nothing to delete” — forever
With min_kept above the actual backup count, the delete step returns an empty list on every single cron run.

backup_auto_delete_days was set to 1000 and backup_auto_max_kept was very high, which compounded it — but the floor alone is sufficient. Whoever set 400 presumably meant it as generous insurance. What they actually configured was mathematically infinite retention: the site could never accumulate enough backups to exceed a floor that grew irrelevant the moment it passed the real count.

One line worth internalizing: Moodle will not warn you about this. There is no "your retention settings can never delete anything" check. The disk is the warning.

Diagnosis

How to confirm backups are what's eating your disk

Don't guess from du output — attribute the space in the database, where every stored file has a row. Automated backups sit in mdl_files under component='backup':

SELECT SUM(filesize)/1e9 AS gb, COUNT(*)
FROM mdl_files WHERE component='backup';

On this site that returned ~5,900 GB across ~80,000 rows. If your number is a large fraction of your volume, you've found it. A per-course count (group by contextid) shows the accumulation pattern — ours was a nearly uniform ~268 per course, which is itself a tell: uniform counts mean retention never ran, not that a few courses are misbehaving.

Then read your three settings, either in Site administration → Courses → Backups → Automated backup setup or from the CLI:

php admin/cli/cfg.php --component=backup --name=backup_auto_max_kept
php admin/cli/cfg.php --component=backup --name=backup_auto_min_kept
php admin/cli/cfg.php --component=backup --name=backup_auto_delete_days

The smoking gun is backup_auto_min_kept greater than the per-course backup counts you just measured. If that's what you see, deletion has never fired and never will.

The fix

Turn deletion back on with one setting

Record the current values first — you want a rollback line in your notes before you change retention on a production site. Then set sane retention through the official CLI. No direct database writes:

php admin/cli/cfg.php --component=backup --name=backup_auto_delete_days --set=0
php admin/cli/cfg.php --component=backup --name=backup_auto_max_kept   --set=<N>

The non-obvious part is why delete_days=0 works. backup_auto_min_kept is only consulted inside the if (deletedays > 0) branch of the retention logic. Set delete_days to 0 and that entire branch is skipped — the broken min_kept=400 stops blocking deletion without you touching it, and the simple rule takes over: keep the newest max_kept backups per course, delete the rest.

before: delete_days = 1000 if (deletedays > 0) → branch runs min_kept = 400 floor consulted 268 ≤ 400 → deletes nothing, keeps all 268 backups after: delete_days = 0 if (deletedays > 0) → branch skipped min_kept never consulted keep newest max_kept N, delete the other ~260 per course
min_kept only exists inside the delete_days branch. Zeroing delete_days sidesteps the broken floor entirely.

Two things we deliberately did not do, both tempting:

  • We didn't lower min_kept instead. It would also have worked, but it leaves the age-based branch active with three interacting knobs to reason about. delete_days=0 reduces the system to one rule you can hold in your head. Fewer moving parts in retention logic is a feature.
  • We didn't rm the .mbz files. Deleting 80,000 files out from under mdl_files leaves the database referencing content that no longer exists. It looks like the fast path; it's actually how you turn a disk problem into a data-integrity problem.

Before you apply this: write down the old values of all three settings. Retention changes on a production site should always ship with a one-line rollback.

Verification

Prove the fix before you trust it

We didn't want the first real test of a mass deletion to be the mass deletion. So before applying the new config, we replicated the get_backups_to_delete() algorithm in a read-only script and ran it against live data under both configurations. Under the old settings it reported 0 deletions — which is the bug, demonstrated, not just theorized. Under the new settings it reported the expected N per course. If your simulation doesn't show exactly that flip, stop and figure out why before changing anything.

Once applied, don't expect the disk graph to drop overnight, for two structural reasons:

First, deletion runs inside each course's own automated backup task. There is no site-wide purge pass — courses shed their backlog one by one as their backup slot comes around, which conveniently self-paces the I/O across nights instead of hammering the volume at once.

Second, "delete" doesn't mean delete yet:

.mbz in moodledata renamed to trashdir O(1) — same filesystem, frees nothing yet file-trash cleanup task (within hours) space freed
Deletion is a rename into moodledata/trashdir. Disk space returns only after the trash cleanup task unlinks the files.

The rename is the good news hidden in this: because trashdir is on the same filesystem, moving a 2 GB backup to trash is O(1) — no copy, no temporary double-storage, no extra space needed even at 96% full. The space then comes back within hours as the cleanup task unlinks the trash. Watch df over the following days and you'll see a staircase, one step per night of backup runs.

Gotchas

The edge cases that will confuse you afterward

Skipped courses keep their backlog. If you skip backups for courses unmodified in N days (a sensible setting on its own), those courses never run their backup task — and since deletion happens inside that task, they never shed their old backups either. Weeks after the fix, your most abandoned courses will still be sitting on 268 backups each. Either live with it, or temporarily disable the skip setting for one full cycle to force a cleanup pass, then turn it back on.

This was never disaster recovery. With backup_auto_storage=0, every one of those 80,000 backups lived inside moodledata — on the same volume as the live site they were supposedly protecting. One disk failure takes the site and all 6 TB of "backups" together. Moodle's own documentation says course backups should never be your primary backup, and we agree: automated course backups are for restoring an accidentally wrecked course, not for surviving a dead server. You still need real off-server backups of the database and moodledata.

The general lesson: retention settings interact, and Moodle validates none of the combinations. Any time you change max_kept, min_kept, or delete_days, do the arithmetic against your actual per-course backup counts — a floor above the real count means deletion is off, whatever the other settings say.

Everything above is enough to diagnose and fix this yourself — that's the point of writing it down. If you'd rather have someone whose job is watching for exactly this kind of slow-motion failure, disk attribution, retention sanity checks, and real off-server backups are part of our managed Moodle hosting. Or if you just want a second pair of eyes on your retention settings before you pull the trigger, send us your three values and your per-course counts — we reply within one business day.

Quick answers

Questions people ask about this

Why is my moodledata folder so huge?

On most Moodle sites with automated course backups enabled, the bulk of moodledata is old .mbz backup files, not course content. Run a sum over mdl_files where component='backup' — on the site in this article that one component accounted for 5.9 TB of a 6 TB volume. If that number dominates, the problem is backup retention, not your courses.

Is it safe to delete old .mbz files from moodledata manually?

No. Every file in moodledata has a corresponding row in mdl_files, and deleting the file directly leaves the database pointing at content that no longer exists. Fix the retention settings instead and let Moodle's own backup task delete through its normal path, which keeps the file table consistent.

What do backup_auto_max_kept, backup_auto_min_kept, and backup_auto_delete_days actually do?

max_kept caps how many automated backups each course keeps. delete_days additionally deletes backups older than that many days — but only down to the min_kept floor. The trap: min_kept is only consulted when delete_days is greater than zero, and if min_kept is set higher than the number of backups a course has, the age-based deletion can never remove anything.

I fixed the settings — why hasn't any disk space been freed yet?

Two delays are built in. Deletion runs inside each course's own automated backup task, so courses shed their backlog as their backups come around, not all at once. And a deleted backup is first renamed into moodledata/trashdir, which frees nothing until the file-trash cleanup task unlinks it — usually within a few hours.

Why do some courses still have hundreds of old backups weeks after the fix?

Courses that haven't been modified in longer than your skip threshold never run their automated backup task at all — and deletion only happens inside that task. Untouched courses keep their entire backlog until someone edits them. You can temporarily disable the skip setting to force one full pass, then re-enable it.

Can I use Moodle's automated course backups as my site backup?

You shouldn't. With backup_auto_storage=0 the backups live inside moodledata on the same volume as the live site, so a disk failure takes both. Moodle's own documentation says course backups should never be your primary backup — you still need real off-server backups of the database and moodledata.