One of the standing jobs every server needs
Retention is the second half of every backup and every log directory: the dated folder script creates one directory per run, and something has to remove the old ones or the disk fills on a schedule you did not choose. This script is that something, for the directories logrotate does not own. Where it sits among the other unattended jobs is in Bash Scripts Every Sysadmin Needs.
logrotate handles /var/log. It does nothing for /var/backups/myapp/2026-09-10/, for the export directory a nightly job writes to, or for the log folder an application creates under its own home and appends to forever. Those directories grow until df says 100% and every write on the box fails at once — the database, the web server, your editor's autosave, all in the same minute. The find -mtime one-liner is the fast fix for flat log files. This script is the version for dated folders, with the two guards the one-liner lacks: it keeps the newest N no matter how old they are, and it refuses to run at all when the pattern matches nothing.
The Script
Save as log-retention-cleanup.sh. Dry run is the default; nothing is deleted without --apply.
What Does a Dry Run Look Like?
Exercised here on 2026-09-10 against a scratch tree of twelve weekly backup folders, 2026-06-25 through 2026-09-10, each holding one app.log of a different size, with touch -d setting the modification times to match the names. A README.txt sits beside them to prove the pattern excludes it:
Keep five, delete older than thirty days, no --apply:
Twelve matched, README.txt did not, and the five newest (2026-08-13 onward) are not in the list even though 2026-08-13 is within a day of the cutoff. The seven older than thirty days are, with their sizes, so the number that matters — 19M back — is on the screen before anything is deleted.
How Does --keep Override --days?
Same tree, same thirty days, but --keep 9:
2026-08-06, 2026-07-30 and 2026-07-23 are older than thirty days and survive anyway, because they are inside the newest nine. That is the guard against the failure the age-only one-liner cannot prevent: a backup job that has been silently broken for six weeks leaves a directory where everything is older than thirty days, and a pure -mtime +30 -delete removes the last good copy you have. Position protects it; age alone does not.
What Happens With a Wrong Pattern?
A pattern that matches nothing is the most dangerous outcome, not the safest one, because a cron job that "cleaned" nothing for a month is exactly how the disk fills:
Exit code 1. Cron mails it, a wrapper can trap it, and nothing was touched.
What Does --apply Do?
The same command as the first dry run with --apply added prints the identical list under a Removing header, deletes each entry with rm -rf --, and confirms the count:
ls afterwards: 2026-08-13 2026-08-20 2026-08-27 2026-09-03 2026-09-10 README.txt. A second --apply run on the result matched five, found nothing beyond the newest five older than thirty days, and exited 0 with nothing to remove — the steady state every subsequent cron tick lands in.
How Do I Schedule It?
Nightly, after the backup job that feeds the directory has finished:
One line per managed directory. --keep should be at least the number of runs you want to survive a broken producer, and --days at least your restore window. If the backup job and this job could ever overlap, wrap this one in flock so a folder is never deleted while it is being written.
A retention script that runs unattended for years wants the same things every unattended script wants: a lock, a log line that proves it ran, an alert when exit 1 fires. The Production Bash Toolkit packages those once as bashlib.sh and a script template, so retention for the next directory is a crontab line.
Frequently Asked Questions
Why not use logrotate for this?
logrotate is the right tool for a single append-only log file it owns: it renames, compresses, and signals the daemon. It has no concept of a directory of dated folders, cannot express "keep the newest seven regardless of age", and does nothing for a backup tree or an export directory. This script covers those and leaves /var/log to logrotate.
What happens if I point it at the wrong directory?
Three things protect you. It is a dry run unless you pass --apply. If the pattern matches nothing it exits 1 with a warning, so a typo cannot pass as a clean run. And --keep protects the newest N entries by position, so a directory where everything is old still keeps its most recent N. It also refuses / itself.
Does --keep count by the date in the name or by modification time?
Modification time, read with find -printf %T@. Names are never parsed, which is why the pattern is free-form. The consequence: cp -r an old backup into the directory and its fresh mtime makes it the newest entry. Use cp -a or rsync -a to preserve timestamps.
Can it delete files as well as folders?
Yes. Any direct child of the target that matches the pattern is a candidate, file or directory, and goes with rm -rf --. Use --pattern '*.log' for flat log files or --pattern '20??-??-??' for dated folders. It never descends below one level: a matching directory is removed whole, and nothing inside a non-matching one is touched.
Does this work on macOS?
Not as written. It relies on GNU find -printf, sort -z, date -r, and du -shc; BSD find has no -printf. On macOS install coreutils and findutils from Homebrew and call gfind, gdate, gdu, or run it on the Linux host that holds the data.
Part of the bash snippets collection
Related Scripts
- Create a Dated Folder — the producer side: one
YYYY-MM-DDdirectory per run - Delete Old Log Files — the
find -mtime +N -deleteone-liner for flat log files - Automated File Backup — the backup job whose output this script prunes