Skip to content

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:

  1. only run it against the test directory created above (~/lab/log-cleanup-project/logs), never against /var/log;
  2. always run it first without the flag (dry-run mode) and read the output carefully;
  3. only add the --apply flag once you're fully confident the dry-run result is correct;
  4. 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:

chmod +x log_cleanup.sh
./log_cleanup.sh

Expected result:

Error: not enough arguments.
Usage: ./log_cleanup.sh <log_directory> <days> [--apply]

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:

report_dry_run

Verification:

./log_cleanup.sh ~/lab/log-cleanup-project/logs 15

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 --apply show different files — both modes should use the same find_old_logs function; 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.keep was 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 1 limits the search to only the given directory itself; removing it lets find descend into subdirectories too. Keep -maxdepth 1 unless that's deliberately what you want.
  • total_size comes out empty — some versions of du behave differently without xargs, or error out when the file list is empty; this is why 2>/dev/null and ${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:

rm -rf ~/lab/log-cleanup-project

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 as important.log.keep) are never touched;
  • files younger than the age threshold (app-1d.log, app-5d.log, app-10d.log in the example) are preserved;
  • called without arguments or with a nonexistent directory, the script fails clearly with the matching exit code (2 or 1).

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.

References