Shell scripting for daily jobs
Every store box needs the same small jobs done every night: check the disk, clear old logs, report problems. This lesson teaches you to write those jobs as short Bash scripts that stop when something goes wrong, never delete the wrong file, and run on a schedule without anyone watching.
By the end you can
- Write a Bash script with a shebang, quoted variables, arguments and functions
- Make a script stop at the first failure with exit codes and
set -euo pipefail, and know where that safety net has holes - Delete old files safely with
find, a printed list first and a dry-run flag - Schedule a job with cron or a systemd timer, and find its output the next morning
- Check a script with ShellCheck before it goes to 320 store boxes
The everyday picture
Think of the closing checklist taped inside the office door of a store. Count the till. Switch off the bakery oven. Throw away bread older than two days. Lock the back door. A good checklist is precise: "bread older than two days" means the same thing to every person who closes the shop.
A good checklist also says when to stop. "If the till is short by more than $20, do not leave. Call the store manager." Without that line, a tired new staff member finishes the list, locks up and goes home, and the problem is found the next morning when it is much harder to fix.
And the best checklists say "look before you throw": put the old bread on the counter, check the dates, then bin it. A shell script is that checklist written so exactly that a computer can follow it at 2 a.m. The precision, the stop rule and the look-before-you-throw step are what this lesson is about.
A script is a file of commands, read word by word
In The terminal without fear you typed commands one at a time, and in Linux from the command line you used them on a store box. A script is a text file with those same commands in order. The first line is the shebang, which tells Linux which program reads the file:
#!/usr/bin/env bash
# hello-store: print who and where we are
store=$(hostname) # run a command, keep its output
echo "Running on ${store} as $(whoami)"Save it as hello-store, make it executable with chmod +x hello-store, and run it with ./hello-store. On a store box it prints Running on store-0142 as techops. Two details matter on Ubuntu. First, sh is a smaller shell called dash, so sh hello-store ignores the shebang and runs the file with dash, and Bash-only features such as [[ ]] fail. Run scripts as ./name or bash name. Second, store=$(hostname) has no spaces around the equals sign. store = x would try to run a program called store.
$(hostname) is a command substitution: Bash runs the command and pastes its output into the line. This is how a script reads the result of one command and decides what to do next.
Now the most important rule in shell scripting. When Bash meets an unquoted variable like $dir, it does not paste the value in as one piece. It first splits the value at every space, tab and newline (word splitting), then treats any * or ? in the pieces as a glob and expands it into file names. Only then does the command run. So:
dir="/srv/till exports" # a folder name with a space
rm -rf $dir/old # rm receives TWO paths: /srv/till and exports/old
rm -rf "$dir/old" # rm receives one path: /srv/till exports/oldThe first rm deletes the wrong things. The fix is to put double quotes around every variable: "$dir", "$1", "$@". Inside double quotes Bash still replaces the variable, but it keeps the value as one word and does not expand globs. Single quotes stop the replacement completely, so '$dir' is the four characters $dir.
Quotes do not protect you from an empty variable. If dir is empty, rm -rf "$dir"/* becomes rm -rf /*. Write "${dir:?}" in any destructive command: if the variable is unset or empty, Bash prints an error and stops the script before the command runs.
Quick check
backup_dir="/srv/till backups", then the script runs ls $backup_dir without quotes. What does ls receive?
The quotes in the assignment only protect the value while it is stored. When the variable is used without quotes, Bash splits it at the space and ls gets two paths, probably printing "No such file or directory" twice. Quoting the use, ls "$backup_dir", passes one argument. Bash does not stop on its own; it happily runs the wrong command.
Exit codes: how a script knows something failed
Every command ends with an exit code. Zero means success, and any other number means failure. grep, for example, returns 0 when it finds a match, 1 when it finds none, and 2 when it cannot read the file. Bash keeps the last exit code in $?, and if, && and || all decide on it:
if grep -q "PRINTER OFFLINE" /var/log/store-print/print.log; then
echo "printer problem seen today"
fi
mkdir -p /srv/reports && cd /srv/reports # cd only if mkdir worked
systemctl is-active --quiet store-print || echo "print service is down" >&2By default Bash ignores failures and carries on with the next line. On a store box that is dangerous: if cd /srv/reports fails, the next rm runs in whatever folder the script happened to be in. Three settings, written together at the top of almost every script, change that:
set -estops the script when a command fails.set -utreats an unset variable as an error, so a typo like$LOG_DRIstops the script instead of becoming an empty string.set -o pipefailchanges what a pipeline reports. Normally a pipeline's exit code is the last command's. With pipefail, it is the exit code of the last command that failed, so a failure anywhere in the pipe is seen.
grep ERROR gone.log | wc -l, on a log file that does not exist. Without pipefail the successful wc hides the failed grep, and the script reports "0 errors". With pipefail the pipeline fails, and set -e stops the script.set -e is a safety net with holes, and you need to know where they are. It does not fire for a command inside an if or while condition, for any command in an && or || list except the last one, or for a command after !. That is on purpose: those are places where you are already handling the result. Two holes catch people more often. local count=$(cmd) inside a function always succeeds, because the exit code you get is the one from local, not from cmd. Declare first and assign on the next line. And a command that "fails" in a normal way, like grep -c finding no matches, stops the whole script. When no match is a fine answer, say so: count=$(grep -c "PRINTER OFFLINE" print.log || true).
Quick check
A script starts with set -euo pipefail and runs failed=$(grep -c "PRINT FAIL" /var/log/store-print/print.log). Tonight the log has no PRINT FAIL lines. What happens?
grep -c prints 0 but exits with 1 when nothing matches. An assignment from $( ) takes the exit code of the command inside, so set -e stops the script right there. The tempting answer is the first, because the output really is 0. Adding || true keeps the 0 and lets the script go on.
Decisions, loops, functions and arguments
Real jobs need to choose and repeat. Bash's [[ ]] tests a condition and returns 0 or 1, so it plugs straight into if:
if [[ -d "$LOG_DIR" ]]; then echo "folder exists"; fi # -d folder, -f file
if [[ -z "${ALERT_URL:-}" ]]; then echo "no alert URL"; fi # -z empty, -n not empty
if (( use > 85 )); then echo "disk too full"; fi # numbers: use (( )) or -gtThe last line hides a trap. Inside [[ ]], the > sign compares text, letter by letter, so [[ 9 > 10 ]] is true because "9" sorts after "1". For numbers, use (( a > b )) or [[ $a -gt $b ]].
A for loop walks over a list. Loop over a glob directly, never over the output of ls, which breaks on spaces. If nothing matches, Bash leaves the pattern as literal text, so shopt -s nullglob makes an empty match an empty list. A while read loop walks over lines of a file or of a command's output:
shopt -s nullglob
for f in /var/log/store-print/*.log; do
echo "$(basename "$f"): $(wc -l < "$f") lines"
done
# check a list of store boxes from the TechOps jump host
while IFS= read -r box; do
ssh -n "$box" df -h / # -n: do not let ssh swallow the rest of the list
done < stores-north.txtIFS= read -r reads each line exactly as written, keeping leading spaces and backslashes. The -n on ssh matters: without it, ssh reads from the same input as the loop, eats the remaining lines, and the loop stops after the first store.
A function names a group of commands. Inside it, $1, $2 are its own arguments, and local keeps a variable from leaking into the rest of the script. The script itself gets its arguments the same way: $1 is the first, $# is how many there are, and "$@" is all of them, each kept as one word. A while loop with case and shift reads options like --dry-run:
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;; # shift drops $1, the rest move up
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
doneQuick check
A script has use=9 and limit=85, then if [[ $use > $limit ]]; then alert; fi. What happens?
Inside [[ ]], > is a text comparison, so a 9% disk raises an alarm and a 100% disk would not ("1" sorts before "8"). The "integer expression expected" message comes from -gt with a value that is not a number. Use (( use > limit )).
Deleting files safely and running twice safely
Clean-up scripts are where a typo costs the most. The tool for "old files" is find. It walks a folder and checks each file against a list of tests, left to right, and runs an action on the files that pass every test:
find /var/log/store-print -xdev -type f -name '*.log.*' -mtime +14 -printRead it as: in this folder, stay on this disk (-xdev), only regular files (-type f), only rotated logs whose name has something after .log, like print.log.3.gz (-name '*.log.*', quoted so the shell does not expand it), only files last changed more than 14 days ago (-mtime +14), and print the name. That name pattern is your protection for the live print.log, the file the print service is writing right now.
-mtime has a rule that surprises people: find works out a file's age in whole days and drops the fraction. A file that is 14 days and 20 hours old counts as 14, and 14 is not "more than 14". So -mtime +14 matches files at least 15 full days old, and -mtime 14 (no plus) means "exactly 14", which is rarely what you want.
-print first and read the list; the real run adds -delete after the same tests, so it deletes exactly the list you read.Three habits make deletes safe. Print first. Run the exact command with -print, read the list, then add -delete at the end. The order matters: find runs left to right, so find /var/log/store-print -delete -name '*.gz' deletes every file in the folder before the name test is ever checked. Build the dry run into the script as a --dry-run flag that prints instead of deleting, so the next person can check it on a new store box too. Keep the printed list in the real run (-print -delete), so the log says what went.
One more fact about deletes on Linux. If a program still has a file open, deleting it removes the name, but the disk space comes back only when the program closes the file. Delete the live print.log and df still shows the disk full until the print service restarts. Rotated logs are closed, which is one more reason the script only touches those.
A daily job should also be idempotent. A retry, a missed night caught up at boot, or a second engineer running it by hand must not do harm. Use mkdir -p (no error if the folder exists), rm -f (no error if the file is gone), "delete files older than 14 days" instead of "delete the oldest 5 files", and a lock so two copies never run at once: flock -n on a lock file exits straight away if another copy holds it.
Quick check
Today a rotated log is 7 days and 20 hours old. Does find /var/log/store-print -name '*.log.*' -mtime +7 match it?
find rounds the age down to whole days before comparing, so -mtime +7 needs at least 8 full days. The answer "yes" is what most people expect, which is why a clean-up can keep files a day longer than planned. -type f filters out folders; it has nothing to do with age.
Logging and scheduling: cron and systemd timers
A job that runs at 2 a.m. has nobody watching it, so it must write down what it did. The simplest rule: print normal progress to standard output with echo, print problems to standard error with echo "…" >&2, and exit with 0 on success and a non-zero code on failure. The scheduler decides where that output goes.
cron is the classic scheduler. A line has five time fields (minute, hour, day of month, month, day of week) and a command. 30 2 * * * means 02:30 every day. Cron runs jobs with a very small environment: on Ubuntu the PATH is only /usr/bin:/bin, there is no terminal, and output that you do not redirect is mailed to the user if a mail system exists, or lost. So in cron, use full paths, send output somewhere on purpose, and remember that % means "new line" in a crontab, so date +%F must be written date +\%F.
A systemd timer is the modern choice on Ubuntu, and it fixes most of cron's gaps. You write two small files: a .service unit that runs the script once, and a .timer unit that says when. Output goes to the journal, so journalctl -u disk-check shows every night's run, the same way you read service logs in Linux from the command line. A failed run marks the unit as failed, which systemctl --failed lists. Persistent=true runs a missed job at boot if the box was off at the planned time, which happens when a store loses power overnight. RandomizedDelaySec= adds a random wait, so 320 store boxes do not all hit the data centre in the same second. And systemd does not start the service again while the last run is still going.
Before a script goes to every store, run ShellCheck on it. It reads the script without running it and points at missing quotes, masked exit codes, globs in the wrong place and dozens of other traps, each with a code like SC2086 that has its own explanation page. Treat its warnings like a review comment from a senior colleague: fix them, or add a comment saying why the line is fine.
Build it
We will write, check, install and schedule disk-check: a nightly job for an Ubuntu 24.04 store box that cleans old print logs and alerts when the root disk is over 85% full.
-
Write the settings, options and helpers
Save this as
disk-check.sh. The settings sit at the top so they are easy to find. The options loop reads--dry-runand--threshold.ALERT_URLcomes from the environment, so the same script works on every store box.Terminal#!/usr/bin/env bash # disk-check: nightly disk check and print-log clean-up for a store box set -euo pipefail THRESHOLD=85 # alert when / is fuller than this (percent) KEEP_DAYS=14 # delete rotated logs older than this LOG_DIR=/var/log/store-print # the print service writes its logs here DRY_RUN=0 usage() { echo "usage: disk-check [--dry-run] [--threshold N]" >&2; exit 2; } while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1; shift ;; --threshold) [[ "${2:-}" =~ ^[0-9]+$ ]] || usage; THRESHOLD=$2; shift 2 ;; *) usage ;; esac done disk_use_pct() { # "87%" from df becomes 87 df --output=pcent "$1" | tail -n 1 | tr -dc '0-9' } alert() { echo "ALERT: $*" >&2 if [[ -n "${ALERT_URL:-}" ]]; then # a failed send is logged, but must not hide the disk problem curl -fsS --max-time 10 --data-urlencode "host=$(hostname)" \ --data-urlencode "msg=$*" "$ALERT_URL" >/dev/null || echo "alert send failed" >&2 fi } -
Write the clean-up and the main part
Add this below the helpers. The lock stops a second copy,
${LOG_DIR:?}refuses to run with an empty folder name, and the dry run uses exactly the same tests as the real run.Terminalclean_old_logs() { local dir="${1:?}" days="$2" if (( DRY_RUN )); then echo "dry run, would delete:" find "$dir" -xdev -type f -name '*.log.*' -mtime +"$days" -print else find "$dir" -xdev -type f -name '*.log.*' -mtime +"$days" -print -delete fi } exec 9>/run/lock/disk-check.lock # open the lock file as descriptor 9 flock -n 9 || { echo "disk-check is already running" >&2; exit 0; } [[ -d "${LOG_DIR:?}" ]] || { echo "no folder $LOG_DIR" >&2; exit 1; } echo "disk use before: $(disk_use_pct /)%" clean_old_logs "$LOG_DIR" "$KEEP_DAYS" after=$(disk_use_pct /) echo "disk use after: ${after}%" if (( after > THRESHOLD )); then alert "root disk at ${after}% (limit ${THRESHOLD}%)" exit 1 # the unit shows as failed fi echo "ok" -
Check it with ShellCheck, then dry-run it
Install ShellCheck once with
sudo apt install shellcheck. A clean script prints nothing. To see what a warning looks like, here is the output for an older draft that hadfind $dirwithout quotes:Terminalshellcheck disk-check.sh sudo bash disk-check.sh --dry-runOutputIn disk-check.sh line 34: find $dir -xdev -type f -name '*.log.*' -mtime +"$days" -print ^--^ SC2086 (info): Double quote to prevent globbing and word splitting. disk use before: 93% dry run, would delete: /var/log/store-print/print.log.15.gz /var/log/store-print/print.log.16.gz /var/log/store-print/print.log.17.gz disk use after: 93% ALERT: root disk at 93% (limit 85%)The second half is the dry run on a full test box: three files would go, and since nothing was deleted the alert still fires. Read the list. Only when it holds exactly the files you expect do you move on.
-
Install the script and write the two units
installcopies the file and sets its owner and mode in one step. The service runs the script once; the timer says when. Comments in unit files must be on their own lines.Terminalsudo install -o root -g root -m 0755 disk-check.sh /usr/local/sbin/disk-check echo 'ALERT_URL=https://alerts.example.com/techops' | sudo tee /etc/default/disk-checkINI# /etc/systemd/system/disk-check.service [Unit] Description=Nightly disk check and print-log clean-up [Service] Type=oneshot # the dash means: carry on if the file is missing EnvironmentFile=-/etc/default/disk-check ExecStart=/usr/local/sbin/disk-checkINI# /etc/systemd/system/disk-check.timer [Unit] Description=Run disk-check every night [Timer] OnCalendar=*-*-* 02:00:00 # spread 320 store boxes over half an hour RandomizedDelaySec=30min # if the box was off at 02:00, run at the next boot Persistent=true [Install] WantedBy=timers.target -
Turn the timer on and read the next morning's result
Enable the timer (not the service), run the service once by hand to test the full path, and read its log.
Terminalsudo systemctl daemon-reload sudo systemctl enable --now disk-check.timer systemctl list-timers disk-check.timer sudo systemctl start disk-check.service # one run now, as the timer would journalctl -u disk-check.service --since todayOutputNEXT LEFT LAST PASSED UNIT ACTIVATES Wed 2026-10-21 02:17:42 UTC 9h - - disk-check.timer disk-check.service 1 timers listed. Oct 20 16:41:05 store-0142 systemd[1]: Starting disk-check.service - Nightly disk check and print-log clean-up... Oct 20 16:41:05 store-0142 disk-check[5120]: disk use before: 93% Oct 20 16:41:05 store-0142 disk-check[5120]: /var/log/store-print/print.log.15.gz Oct 20 16:41:05 store-0142 disk-check[5120]: /var/log/store-print/print.log.16.gz Oct 20 16:41:05 store-0142 disk-check[5120]: /var/log/store-print/print.log.17.gz Oct 20 16:41:05 store-0142 disk-check[5120]: disk use after: 71% Oct 20 16:41:05 store-0142 disk-check[5120]: ok Oct 20 16:41:05 store-0142 systemd[1]: disk-check.service: Deactivated successfully. Oct 20 16:41:05 store-0142 systemd[1]: Finished disk-check.service - Nightly disk check and print-log clean-up.The exact wording of these lines depends on the systemd version. To check a calendar expression before you trust it,
systemd-analyze calendar '*-*-* 02:00:00'prints the next time it will fire. -
If a box only has cron
The same job as one file in
/etc/cron.d. Files there have a user field after the five time fields. The full path avoids cron's shortPATH, andloggersends the output to the journal under the tagdisk-check. Unlike systemd, cron will start a new run while an old one is still going, which is why the script takes a lock.Terminal# /etc/cron.d/disk-check: minute hour day month weekday user command ALERT_URL=https://alerts.example.com/techops 30 2 * * * root /usr/local/sbin/disk-check 2>&1 | logger -t disk-check
Bring it to your team
Ask these in every review of a script that will run on store boxes.
- "Does it start with
set -euo pipefail, and where does it handle a command that may fail on purpose?" - "Is every variable quoted, and is every path in a delete guarded against being empty?"
- "Show me the dry-run output from one real store box. Which files would it delete?"
- "What happens if it runs twice at once, or twice in a row, or after a night the box was off?"
- "Where do we read its output tomorrow morning, and what tells us it failed?"
- "Is ShellCheck clean, and if not, why is each warning safe?"
Red flags
rm -rf $DIR/*with no quotes and no${DIR:?}guard-deleteon afindcommand that has never been run with-printon a real box- A clean-up that can match the live log file or other services' files
- A cron job with output sent to
/dev/null, so a failure leaves no trace - Every store box scheduled at the same minute to send data to the same central service
Practice
Model the clean-up rule
Bash cannot run in the browser, so model the clean-up rule as a JavaScript function. Each file is { name, day }, where day is the whole day number it was last changed, and nowDay is today. Return the names to delete, in the same order as the input. A file is deleted when its age, nowDay - day, is more than keepDays (like find -mtime +keepDays), except that the keepNewest newest files are always kept, however old they are, so there is always something to look at after a quiet month. No two files share the same day. The starter has two bugs.
function filesToDelete(files, nowDay, keepDays, keepNewest) {
// delete files older than keepDays
return files
.filter((f) => nowDay - f.day >= keepDays)
.map((f) => f.name);
}
const logs = [
{ name: 'print.log.1', day: 99 },
{ name: 'print.log.2', day: 86 },
{ name: 'print.log.3', day: 85 },
{ name: 'print.log.4', day: 70 },
];
test('deletes only files more than keepDays old (age 14 is kept, age 15 goes)', () => {
expect(filesToDelete(logs, 100, 14, 0)).toEqual(['print.log.3', 'print.log.4']);
});
test('always keeps the keepNewest newest files, even when they are old', () => {
const quiet = [{ name: 'a.log.1', day: 60 }, { name: 'a.log.2', day: 50 }, { name: 'a.log.3', day: 40 }];
expect(filesToDelete(quiet, 100, 14, 2)).toEqual(['a.log.3']);
});
test('keeps the input order and works when the list is not sorted', () => {
const mixed = [{ name: 'x.log.4', day: 10 }, { name: 'x.log.1', day: 95 }, { name: 'x.log.3', day: 20 }, { name: 'x.log.2', day: 30 }];
expect(filesToDelete(mixed, 100, 14, 2)).toEqual(['x.log.4', 'x.log.3']);
});
test('an empty folder deletes nothing', () => {
expect(filesToDelete([], 100, 14, 3)).toEqual([]);
});
Show a solution
function filesToDelete(files, nowDay, keepDays, keepNewest) {
// the keepNewest files with the highest day are protected
const newest = [...files]
.sort((a, b) => b.day - a.day)
.slice(0, keepNewest)
.map((f) => f.name);
return files
.filter((f) => nowDay - f.day > keepDays) // strictly more, like -mtime +N
.filter((f) => !newest.includes(f.name))
.map((f) => f.name);
}The starter uses >=, so it deletes a file that is exactly 14 days old, which -mtime +14 keeps. It also has no "keep the newest" rule, so after a quiet month it would delete every log and leave nothing to investigate. Copying the list before sorting keeps the original order for the result.
Review a clean-up script before it ships
A teammate wants to put this on every store box, run nightly, to remove till export files older than 30 days from the folder given as the first argument. Find at least four problems and write a safer version.
#!/usr/bin/env bash
# remove till exports older than 30 days
DIR=$1
cd $DIR
rm -rf $DIR/*.csv
find . -mtime 30 -delete
echo "done"- You spot that there is no
set -euo pipefail, so ifcdfails, the next lines run in the wrong folder - You spot that
$DIRis unquoted and unchecked: empty, it makes thecdgo home and thermtarget/*.csv - You spot that
rm -rf $DIR/*.csvdeletes every export, whatever its age - You spot that
-mtime 30means exactly 30 days, and that thefindhas no-type for name test, so it can delete any file - Your version prints before deleting (a dry-run flag or
-print) and logs what it deleted
Show a model answer
Problems: no strict mode, so a failed cd is ignored; unquoted and unchecked $DIR; the rm line deletes all CSV files, new ones included; -mtime 30 matches only files exactly 30 days old; find . with no type or name test can delete anything in the folder; nothing is printed, so nobody can tell what went. A safer version:
#!/usr/bin/env bash
# clean-exports DIR [--dry-run]: remove till export CSVs older than 30 days
set -euo pipefail
dir="${1:?usage: clean-exports DIR [--dry-run]}"
[[ -d "$dir" ]] || { echo "no folder $dir" >&2; exit 1; }
action=(-print -delete)
[[ "${2:-}" == "--dry-run" ]] && action=(-print)
# no cd needed: find works on the path directly
find "$dir" -xdev -maxdepth 1 -type f -name '*.csv' -mtime +30 "${action[@]}"Check yourself
A nightly script works every time you run it by hand. From cron at 02:30 it fails with logrotate: command not found. logrotate is installed in /usr/sbin. What is the most likely cause?
Your terminal has a long PATH; cron gives jobs a minimal one, and /usr/sbin is not in it. Use the full path, /usr/sbin/logrotate, or set PATH at the top of the script. Cron does respect the shebang when it runs an executable file, and a job in /etc/cron.d can run as root. Strict mode only decides what happens after the failure.
All 320 store boxes run a health script at 02:00 that posts a short report to one central TechOps service. Every night a few dozen posts are refused and those boxes show the job as failed. What is the best fix?
The posts fail because they all arrive in the same second. A random delay spreads the load, so the central service sees a few posts a second instead of 320 at once. Removing set -e or hiding output stops you from seeing real failures, and running every minute multiplies the load and creates the same spike every minute.
A store box disk is at 97%. A teammate deletes the live print.log (6 GB) by hand, but df -h still shows 97%. Why, and what next?
On Linux, deleting an open file removes its name, but the blocks are freed only when the last program holding it closes it. Restarting the print service frees the 6 GB. The lasting fix is a clean-up that never matches the live file. df is not delayed, rm has no trash folder, and a delete without permission prints an error instead of failing silently.
Flashcards
Key takeaways
- Quote every variable and guard destructive paths with
${var:?}; Bash splits and expands anything unquoted. - Start scripts with
set -euo pipefail, and know its holes: conditions,&&and||lists,local x=$(cmd), and commands like grep that return 1 for a normal "no". - Delete with
find, tests first and action last: print the list, add a dry-run flag, and only match closed, rotated files. - Make daily jobs idempotent and take a lock, so retries and catch-up runs are harmless.
- Prefer systemd timers on Ubuntu: output in the journal, failures visible,
Persistent=trueand a random delay. Run ShellCheck before anything ships.
Sources
- Bash Reference Manual, GNU Project
- find(1), including -mtime and -delete, Linux man-pages
- systemd.timer(5), Linux man-pages
- ShellCheck SC2086: double quote to prevent globbing and word splitting, ShellCheck wiki