Project: Log Cleanup Script
Disk-Full Troubleshooting showed that old logs can fill up a disk, and the Healthcheck Script project covered checking disk usage automatically. This final project ties the two together: writing a script that deletes old log files regularly and safely, without waiting for space to run out. "Safely" is not an accidental word here — a log cleanup script, written carelessly, is one of the riskiest script types for losing needed files.
Goal and End Result
The script finds .log-extension files, in a given log directory, older than a specified age (in days). It always runs first in dry-run mode — deleting nothing, only showing what would be deleted — and performs the actual deletion only when called with an explicit --apply flag. This is a safety pattern widely used in real production scripts.
Starting State
mkdir -p ~/lab/log-cleanup-project/logs
cd ~/lab/log-cleanup-project
for i in 1 5 10 20 35; do
touch -d "${i} days ago" "logs/app-${i}d.log"
done
touch -d "3 days ago" logs/important.log.keep
ls -la logs
Files of various ages are artificially created here — touch -d sets a file's modification time to a specified point in the past, letting the find -mtime-based cleanup logic be tested without waiting for real time to pass.
Requirements
find,du,date— available by default on Ubuntu Server LTS;- write (and delete) access to the log directory;
- familiarity with Conditions and Loops and Exit Codes is recommended to understand how the script is structured.
Safety Note
This script irreversibly deletes files. The first time you try this project:
- only run it against the test directory created above (
~/lab/log-cleanup-project/logs), never against/var/log; - always run it first without the flag (dry-run mode) and read the output carefully;
- only add the
--applyflag once you're fully confident the dry-run result is correct; - before using it on a production directory, archive important files first with the Backup Script.
Building It Step by Step
Step 1: arguments and the dry-run flag
#!/bin/bash
set -euo pipefail
# log_cleanup.sh — finds old log files and deletes them (only with --apply)
# Usage: ./log_cleanup.sh <log_directory> <days> [--apply]
LOG_DIR="${1:-}"
MAX_AGE_DAYS="${2:-}"
APPLY_MODE="${3:-}"
if [ -z "$LOG_DIR" ] || [ -z "$MAX_AGE_DAYS" ]; then
echo "Error: not enough arguments." >&2
echo "Usage: $0 <log_directory> <days> [--apply]" >&2
exit 2
fi
if [ ! -d "$LOG_DIR" ]; then
echo "Error: directory not found: $LOG_DIR" >&2
exit 1
fi
Verification:
Expected result:
Step 2: dry run — showing what would be deleted
find_old_logs() {
# "$@" forwards any extra find actions (e.g. -print -delete) to the SAME
# search conditions, so the dry-run and the real deletion can never drift.
find "$LOG_DIR" -maxdepth 1 -name "*.log" -mtime "+${MAX_AGE_DAYS}" "$@"
}
report_dry_run() {
local files
files=$(find_old_logs)
if [ -z "$files" ]; then
echo "No .log files older than ${MAX_AGE_DAYS} days were found."
return 0
fi
echo "The following files are candidates for deletion (older than ${MAX_AGE_DAYS} days):"
echo "$files" | while IFS= read -r file; do
local size
size=$(du -h "$file" | cut -f1)
echo " $file ($size)"
done
local total_size
total_size=$(find_old_logs | xargs du -ch 2>/dev/null | tail -n 1 | cut -f1)
echo "Total space that would be freed: ${total_size:-0}"
}
Add it to the main flow:
Verification:
Expected result:
The following files are candidates for deletion (older than 15 days):
/home/<username>/lab/log-cleanup-project/logs/app-20d.log (0B)
/home/<username>/lab/log-cleanup-project/logs/app-35d.log (0B)
Total space that would be freed: 0B
(Since the files were created empty, size shows as 0B — for real logs this would show the actual size.) Notice that app-1d.log, app-5d.log, app-10d.log, and important.log.keep are not listed: the first three are younger than 15 days, and the last one ends in .log.keep, not .log, so it doesn't match the pattern.
Step 3: real deletion with --apply
apply_cleanup() {
local deleted_count
deleted_count=$(find_old_logs -print -delete | wc -l)
echo "Deleted: $deleted_count file(s)"
}
Here find_old_logs -print -delete passes -print -delete as extra find actions, which the "$@" in find_old_logs appends to the exact same match conditions used by the dry run. This is why the function was written with "$@": without it, the appended -print -delete would be dropped and apply_cleanup would silently delete nothing while still reporting a count — a dangerous "looks like it worked" bug. Because both modes call one function, the dry-run preview and the real deletion are guaranteed to target the identical set of files.
Update the main flow — without --apply, only a report; with it, real deletion:
if [ "$APPLY_MODE" = "--apply" ]; then
report_dry_run
echo "--- --apply mode: deletion starting ---"
apply_cleanup
else
report_dry_run
echo "--- This was only a preview. Add the --apply flag to actually delete files. ---"
fi
Verification (first without the flag, then with --apply):
./log_cleanup.sh ~/lab/log-cleanup-project/logs 15
./log_cleanup.sh ~/lab/log-cleanup-project/logs 15 --apply
ls ~/lab/log-cleanup-project/logs
Expected result: the first call only shows the listing and deletes nothing; the second actually deletes app-20d.log and app-35d.log, while app-1d.log, app-5d.log, app-10d.log, and important.log.keep are preserved.
Danger
find ... -delete is irreversible, equivalent to rm. Always run -delete against the exact same find invocation — the same -maxdepth, -name, and -mtime conditions — that was tested in dry-run. Writing the conditions differently between the dry-run and the real call (for example, forgetting -maxdepth 1 in one of them) can delete an entirely different set of files.
Practical Scenario: Running Regularly via cron
Problem: log cleanup needs to run automatically every day, not by hand, but a failure shouldn't pass silently — it should be reported.
0 3 * * * /home/<username>/scripts/log_cleanup.sh /var/log/myapp 30 --apply >> /var/log/myapp/cleanup-cron.log 2>&1
This crontab entry runs the script every day at 03:00, in --apply mode, writing its entire output (both success and error messages together) to cleanup-cron.log. cron syntax and how its environment differs from an interactive shell are covered in depth in cron Basics and cron Environment and Logs; what matters here is that in production, a script like this should first be tested manually, then in dry-run mode under cron, and only then scheduled with --apply.
The Complete Script
#!/bin/bash
set -euo pipefail
# log_cleanup.sh — finds old log files and deletes them (only with --apply)
# Usage: ./log_cleanup.sh <log_directory> <days> [--apply]
LOG_DIR="${1:-}"
MAX_AGE_DAYS="${2:-}"
APPLY_MODE="${3:-}"
if [ -z "$LOG_DIR" ] || [ -z "$MAX_AGE_DAYS" ]; then
echo "Error: not enough arguments." >&2
echo "Usage: $0 <log_directory> <days> [--apply]" >&2
exit 2
fi
if [ ! -d "$LOG_DIR" ]; then
echo "Error: directory not found: $LOG_DIR" >&2
exit 1
fi
find_old_logs() {
# "$@" forwards any extra find actions (e.g. -print -delete) to the SAME
# search conditions, so the dry-run and the real deletion can never drift.
find "$LOG_DIR" -maxdepth 1 -name "*.log" -mtime "+${MAX_AGE_DAYS}" "$@"
}
report_dry_run() {
local files
files=$(find_old_logs)
if [ -z "$files" ]; then
echo "No .log files older than ${MAX_AGE_DAYS} days were found."
return 0
fi
echo "The following files are candidates for deletion (older than ${MAX_AGE_DAYS} days):"
echo "$files" | while IFS= read -r file; do
local size
size=$(du -h "$file" | cut -f1)
echo " $file ($size)"
done
local total_size
total_size=$(find_old_logs | xargs du -ch 2>/dev/null | tail -n 1 | cut -f1)
echo "Total space that would be freed: ${total_size:-0}"
}
apply_cleanup() {
local deleted_count
deleted_count=$(find_old_logs -print -delete | wc -l)
echo "Deleted: $deleted_count file(s)"
}
if [ "$APPLY_MODE" = "--apply" ]; then
report_dry_run
echo "--- --apply mode: deletion starting ---"
apply_cleanup
else
report_dry_run
echo "--- This was only a preview. Add the --apply flag to actually delete files. ---"
fi
Troubleshooting Tips
- Dry-run and
--applyshow different files — both modes should use the samefind_old_logsfunction; if the conditions are written separately in two places, they can easily drift apart. This is exactly why the condition in this script is written in only one function, once. - A file like
important.log.keepwas unexpectedly deleted — the-name "*.log"pattern requires the name to end exactly in.log; if it's broadened (e.g. to-name "*log*"), files with other names can match too. Always keep the pattern as narrow and specific as possible. - Subdirectories inside the directory also got cleaned up —
-maxdepth 1limits the search to only the given directory itself; removing it letsfinddescend into subdirectories too. Keep-maxdepth 1unless that's deliberately what you want. total_sizecomes out empty — some versions ofdubehave differently withoutxargs, or error out when the file list is empty; this is why2>/dev/nulland${total_size:-0}provide a fallback value for the empty case.
Rollback and Cleanup
If a needed file was mistakenly deleted, it can only be restored from a backup taken beforehand (log files usually have no "trash" mechanism) — which is exactly why archiving with the Backup Script was recommended before running this on a production directory. Once the lab is done:
Final Assessment Criterion
The project is considered successful if all of the following hold:
- called without a flag, the script deletes nothing, only showing the candidate list and total size;
- called with
--apply, only exactly the files shown in the dry-run are deleted; - files not ending in
.log(such asimportant.log.keep) are never touched; - files younger than the age threshold (
app-1d.log,app-5d.log,app-10d.login the example) are preserved; - called without arguments or with a nonexistent directory, the script fails clearly with the matching exit code (
2or1).
Summary
The "14. Bash Scripting" module concludes here: starting from Basics, moving through Variables and Arguments, Conditions and Loops, Functions, and Exit Codes, it came together in three production scripts — backup, healthcheck, and log cleanup. One idea repeats across all three: always insert a read/verify step before a dangerous action (a dry run, verify_backup, -print before -delete). The next "15. Scheduling, Backup, and Automation" module picks up exactly these scripts and starts running them unattended, on a schedule, via cron and systemd timers.