On one of my servers, I created a subdomain called “uploads,” serving as a temporary spot for files I want to share or transfer. However, this directory keeps expanding uncontrollably. To manage it, I wrote a Bash script that will run directly or as a cron job to remove files that have overstayed their welcome, specified in the script in days.
You might call ~330 lines of code—in Bash, nonetheless—overengineering for this kind of task, and I wouldn’t necessarily disagree with you. But once started, I tried to make this single script file as useful as possible. You can either run it as a normal script or add it as a cron job, as I wrote earlier.
This is what I did, and now the script does the housework for me every 30 days and deletes every file or folder that is older than 30 days. Of course, you can adjust this in the script itself. Run this script with the bash
command instead of sh
. Anything but bash
will likely cause syntax errors.
In addition to this blog, I uploaded the script to both GitLab (“Snippet”) and to GitHub (“Gist”), which includes a mostly AI-created file with instructions, examples, and all information that shouldn’t leave any questions open about its usage. God, even the featured image for this post is AI-generated—everything but the code and comments. Yeah, I’m still that old-school, I still enjoy writing scripts by hand instead of “vibe-coding” the joy out of my work.
The Script
Have a look at it, copy it, use it, learn from it, or just insult me for overengineering something trivial as this ¯\_(ツ)_/¯
#!/bin/bash
# Define the script to execute with bash
# Script Name: Old Files Cleaner
# Description: Deletes files older than a specified age (days) within a target
# directory, excluding specified directories and file patterns.
# Includes dry-run mode. Meant to be used as a cron job.
# Author: Kolja Nolte
# Email: kolja.nolte@gmail.com
# Website: https://www.kolja-nolte.com
# Date: 2025-04-21
# Version: 1.0.0
# License: MIT License
#
# Dependencies: bash, find, xargs, rm, date, printf
#
# Usage: ./auto-deleter.sh [--dry-run] [--target-dir <dir>] [--exclude-dir <dir>] [--exclude-file <pattern>]
# Example cron job: 0 0 * * * /path/to/auto-deleter.sh (daily at midnight)
# --- Default Configuration ---
# Define if the script should run in dry-run mode
DRY_RUN=false
# Define the target directory to clean
TARGET_DIR="/mnt/storage/backup/uploads"
# Define the age (in days) of files to be deleted
DAYS_OLD=30
# Define an array of directories to exclude from the cleanup process
EXCLUDE_DIRS=(".well-known")
# Define an array of file patterns to exclude from the cleanup process
EXCLUDE_FILES=("index.php" "index.html")
# --- Function Definitions ---
# Define the log function
log() {
# Output the current date and time, followed by the provided message
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
}
# Define the log_error function
log_error() {
# Output the current date and time, followed by "ERROR:" and the provided message to standard error
echo "$(date '+%Y-%m-%d %H:%M:%S') - ERROR: $1" >&2
}
# Define the usage function
usage() {
# Display the script's usage instructions
echo "Usage: $0 [-n] [-t <dir>] [-d <days>] [-e <dir>] [-f <pattern>] [-h]"
# Display the available options
echo "Options:"
# Explain the -n option
echo " -n Dry-run mode. List files that would be deleted without deleting them."
# Explain the -t option
echo " -t <dir> Target directory to clean. (Default: ${TARGET_DIR})"
# Explain the -d option
echo " -d <days> Delete files older than this many days. Must be a non-negative integer. (Default: ${DAYS_OLD})"
# Explain the -e option
echo " -e <dir> Directory name (relative to target) to exclude. Can be used multiple times."
# Explain the default exclusions for directories
echo " (Default exclusions: ${EXCLUDE_DIRS[*]})"
# Explain the -f option
echo " -f <pattern> File name pattern (e.g., '*.log', 'index.php') to exclude. Can be used multiple times."
# Explain the default exclusions for files
echo " (Default exclusions: ${EXCLUDE_FILES[*]})"
# Explain the -h option
echo " -h Show this help message and exit."
}
# Define the err_report function
err_report() {
# Store the line number where the error occurred
local line_num="$1"
# Store the exit code (defaulting to 1 if not provided)
local exit_code="${2:-1}"
# Log an error message indicating the script failure and the location
log_error "Script failed on or near line ${line_num} with exit code ${exit_code}."
# Exit the script with the provided exit code
exit "$exit_code"
}
# --- Argument Parsing ---
# Initialize a flag to track if CLI excluded directories are set
cli_exclude_dirs_set=false
# Initialize a flag to track if CLI excluded files are set
cli_exclude_files_set=false
# Parse command-line options using getopts
while getopts ":hnt:d:e:f:" opt; do
# Use a case statement to handle each option
case $opt in
# If the -h option is provided
h)
# Display usage information and exit
usage; exit 0 ;;
# If the -n option is provided
n)
# Set DRY_RUN to true
DRY_RUN=true ;;
# If the -t option is provided
t)
# Set TARGET_DIR to the provided argument
TARGET_DIR="$OPTARG" ;;
# If the -d option is provided
d)
# Validate DAYS_OLD is a non-negative integer
if [[ "$OPTARG" =~ ^[0-9]+$ ]]; then
# Set DAYS_OLD to the provided argument if it's valid
DAYS_OLD="$OPTARG"
else
# Log an error message if the value is invalid
log_error "Invalid value for days (-d): '$OPTARG'. Must be a non-negative integer."
# Display usage information
usage
# Exit with an error code
exit 1
fi
;;
# If the -e option is provided
e)
# Check if this is the first -e option
if ! $cli_exclude_dirs_set; then
# Clear the default EXCLUDE_DIRS array
EXCLUDE_DIRS=()
# Set the cli_exclude_dirs_set flag to true
cli_exclude_dirs_set=true
fi
# Add the provided directory to the EXCLUDE_DIRS array
EXCLUDE_DIRS+=("$OPTARG")
;;
# If the -f option is provided
f)
# Check if this is the first -f option
if ! $cli_exclude_files_set; then
# Clear the default EXCLUDE_FILES array
EXCLUDE_FILES=()
# Set the cli_exclude_files_set flag to true
cli_exclude_files_set=true
fi
# Add the provided file pattern to the EXCLUDE_FILES array
EXCLUDE_FILES+=("$OPTARG")
;;
# If an invalid option is provided
\?)
# Log an error message indicating the invalid option
log_error "Invalid option: -$OPTARG"
# Display usage information
usage
# Exit with an error code
exit 1 ;;
# If an option requiring an argument is missing the argument
:)
# Log an error message indicating the missing argument
log_error "Option -$OPTARG requires an argument."
# Display usage information
usage
# Exit with an error code
exit 1 ;;
esac
done
# Shift the option index to remove parsed options
shift $((OPTIND-1))
# --- Script Execution ---
# Enable strict mode: exit on error (-e), undefined variable (-u), pipe failure (-o pipefail)
set -euo pipefail
# Trap errors to call err_report function
trap 'err_report "$LINENO" "$?"' ERR
# Validate target directory
if [ ! -d "$TARGET_DIR" ]; then
# Log an error if the target directory does not exist or is not a directory
log_error "Target directory '$TARGET_DIR' does not exist or is not a directory."
# Exit with an error code
exit 1
fi
# Log configuration
log "Starting cleanup process..."
log "Target Directory: '$TARGET_DIR'"
log "Delete files older than: $DAYS_OLD days"
log "Excluded Directories: ${EXCLUDE_DIRS[*]:-(none)}"
log "Excluded File Patterns: ${EXCLUDE_FILES[*]:-(none)}"
log "Mode: $(if $DRY_RUN; then echo 'Dry Run'; else echo 'Actual Deletion'; fi)"
# --- Build find command ---
# Initialize the find command with the target directory and minimum depth
find_cmd=(find "$TARGET_DIR" -mindepth 1)
# Add directory exclusions (pruning)
if [ ${#EXCLUDE_DIRS[@]} -gt 0 ]; then
# Start pruning group
find_cmd+=(\()
# Initialize a flag to track the first directory
first_dir=true
# Iterate over the EXCLUDE_DIRS array
for dir_pattern in "${EXCLUDE_DIRS[@]}"; do
# Check if the directory pattern is empty
if [[ -z "$dir_pattern" ]]; then
# Log a warning if the directory pattern is empty
log "Warning: Skipping empty directory exclusion pattern."
# Skip to the next iteration
continue
fi
# Add an OR condition if it's not the first directory
if ! $first_dir; then
# Add OR condition
find_cmd+=(-o)
fi
# Clean the pattern: remove leading/trailing slashes for reliable matching with find's output
clean_pattern=$(echo "$dir_pattern" | sed 's:/*$::; s:^/*::')
# Use -path which matches the whole path string. Need to match relative to TARGET_DIR.
find_cmd+=(-path "$TARGET_DIR/$clean_pattern")
# Set the first_dir flag to false
first_dir=false
done
# End group, prune matching paths, OR continue with main criteria
find_cmd+=(\) -prune -o)
fi
# Add main selection criteria (type, age, file exclusions)
find_cmd+=(\()
# Match regular files older than N days
find_cmd+=(-type f -mtime "+$DAYS_OLD")
# Add file pattern exclusions
if [ ${#EXCLUDE_FILES[@]} -gt 0 ]; then
# Start NOT group for file patterns
find_cmd+=(-not \()
# Initialize a flag to track the first file
first_file=true
# Iterate over the EXCLUDE_FILES array
for file_pattern in "${EXCLUDE_FILES[@]}"; do
# Check if the file pattern is empty
if [[ -z "$file_pattern" ]]; then
# Log a warning if the file pattern is empty
log "Warning: Skipping empty file exclusion pattern."
# Skip to the next iteration
continue
fi
# Add an OR condition if it's not the first file
if ! $first_file; then
# Add OR condition
find_cmd+=(-o)
fi
# Match the file name
find_cmd+=(-name "$file_pattern")
# Set the first_file flag to false
first_file=false
done
# End NOT group
find_cmd+=(\))
fi
# End main criteria group, print null-terminated results
find_cmd+=(\) -print0)
# --- Execute find and process results ---
# Log "Searching for files to delete..."
log "Searching for files to delete..."
# Execute the find command, capturing null-separated output
file_list_null=$("${find_cmd[@]}")
# Count the number of files found
file_count=$(echo -n "$file_list_null" | tr '\0' '\n' | grep -c .) || true
# Log the number of files found
log "Found $file_count file(s) matching the criteria."
# --- Perform Action (Dry Run or Deletion) ---
# Check if dry run mode is enabled
if [ "$DRY_RUN" = true ]; then
# Log "--- DRY RUN MODE ---"
log "--- DRY RUN MODE ---"
# Check if any files were found
if [ "$file_count" -gt 0 ]; then
# Log "Files that would be deleted:"
log "Files that would be deleted:"
# Print the list, converting nulls to newlines for readability
echo -n "$file_list_null" | tr '\0' '\n'
else
# Log "No files matched the criteria. Nothing would be deleted."
log "No files matched the criteria. Nothing would be deleted."
fi
# Log "--- END DRY RUN ---"
log "--- END DRY RUN ---"
else
# Log "--- ACTUAL DELETION MODE ---"
log "--- ACTUAL DELETION MODE ---"
# Check if any files were found
if [ "$file_count" -gt 0 ]; then
# Log "Proceeding with deletion of $file_count file(s)..."
log "Proceeding with deletion of $file_count file(s)..."
# Pipe the null-separated list directly to xargs for deletion
echo -n "$file_list_null" | xargs -0 --no-run-if-empty rm -f
# Log "Deletion command executed for $file_count file(s)."
log "Deletion command executed for $file_count file(s)."
else
# Log "No files matched the criteria. Nothing to delete."
log "No files matched the criteria. Nothing to delete."
fi
fi
# Log "Cleanup process completed successfully."
log "Cleanup process completed successfully."
# Exit with success code
exit 0
View on GitLab