Skip to content
Commitpedia
Log in Join & edit

Turn a one-liner into a robust bash script: strict mode, options, logging, traps, locking and a schedule

Grow a find-and-gzip one-liner step by step into a dependable bash script with strict mode, getopts, usage text, logging, error traps, dry-run, a lock and a cron or systemd timer schedule.

Edit history of Turn a one-liner into a robust bash script: strict mode, options, logging, traps, locking and a schedule: 1 recorded edit
Edit history · 1 edit
Type
Article
Edits
1
Editors
1
First issued
15 Oct 2026
Last edit
15 Oct 2026
Reading
14 min

Most useful scripts start as a one-liner you typed once and then pasted into a file. That file then runs unattended at 2 a.m., with no one watching, on a directory that sometimes does not exist. This tutorial takes a real one-liner, compressing old log files, and adds one safety feature per step until it is a script you can schedule with confidence.

It is for people who can write simple shell commands and want to know what makes a script robust. The script was run end to end on macOS with bash 3.2 (the version Apple ships), including every failure case shown, so it also works on the newer bash found on Linux. Scheduling with cron and systemd is shown from the official documentation and was not executed.

What you will build

  • compress-old-logs.sh, which gzips *.log files older than N days in a directory.
  • Options -d DIR, -a DAYS, -n (dry run) and -h (help), with input validation.
  • Timestamped log lines on stderr, an error trap that reports the failing line, a lock that prevents two copies running at once, and clean exits on Ctrl+C or kill.
  • A cron entry and a systemd service plus timer that run it every night.

Before you start

  • bash 3.2 or newer, and the standard find and gzip tools (present on Linux and macOS).
  • A test folder you can safely fill with fake logs. Never develop a script like this against real log directories.
  • For scheduling: a Linux machine with cron or systemd and permission to add a job for your user, or sudo for system-wide units.
  • Optional: ShellCheck, a linter that finds common shell bugs.
  • Time: about 60 minutes.
  • Knowledge: running commands, pipes and redirection, exit codes (0 means success) and file permissions (chmod +x).

Step 1: Start with the one-liner and a test folder

Why: you need a safe playground with files of known ages. touch -t sets a file’s modification time (format [[CC]YY]MMDDhhmm), so you can create logs that look 1, 3, 10, 20 and 30 days old. A name with spaces is included on purpose because it breaks many naive scripts.

mkdir -p ~/script-lab/demo-logs
cd ~/script-lab
for d in 1 3 10 20; do
  echo "log line" > "demo-logs/app-$d-days.log"
done
touch -t 202609250900 demo-logs/app-20-days.log
touch -t 202610050900 demo-logs/app-10-days.log
echo x > "demo-logs/name with spaces.log"
touch -t 202609150900 "demo-logs/name with spaces.log"
ls -l demo-logs

Adjust the dates so they are 10, 20 and 30 days before today. Now the one-liner. Always run the find part on its own first to see what it would touch:

find demo-logs -type f -name '*.log' -mtime +7
find demo-logs -type f -name '*.log' -mtime +7 -exec gzip {} \;

-mtime +7 means “modified more than 7 full days (24-hour periods) ago”, so a file exactly 7.5 days old does not match yet. The one-liner works, but it is silent, cannot be tested without changing files, hard-codes the folder and the age, and keeps going in strange ways when something fails. Recreate the test files before continuing (gunzip demo-logs/*.gz undoes the compression).

Step 2: Put it in a file with strict mode

Why: bash’s defaults are forgiving in ways that hide bugs. Strict mode is a set of options at the top of the script:

  • set -e: exit when a command fails. The bash manual lists exceptions: commands tested by if, while or until, and any command in an && or || list except the last are allowed to fail.
  • set -u: using an unset variable is an error, so a typo like $LOG_DRI stops the script instead of silently expanding to nothing.
  • set -o pipefail: a pipeline fails if any command in it fails, not just the last one.
  • set -E: the ERR trap (step 5) is inherited by functions, which the script uses everywhere.
#!/usr/bin/env bash
set -Eeuo pipefail

find demo-logs -type f -name '*.log' -mtime +7 -exec gzip {} \;

#!/usr/bin/env bash finds bash on the PATH, which works on both Linux and macOS. Save the file as compress-old-logs.sh, then chmod +x compress-old-logs.sh and check the syntax without running anything with bash -n compress-old-logs.sh.

Step 3: Add options, validation and usage text

Why: a scheduled script must take its settings as arguments, refuse bad input and explain itself. getopts is built into bash. The leading colon in ':d:a:nh' turns on silent error mode: an unknown option arrives as ? and a missing value as :, with the offending letter in OPTARG, so the script can print its own message.

usage() {
  cat <<USAGE
Usage: $SCRIPT_NAME -d DIR [-a DAYS] [-n] [-h]
...
USAGE
}

parse_args() {
  local opt
  while getopts ':d:a:nh' opt; do
    case "$opt" in
      d) LOG_DIR=$OPTARG ;;
      a) DAYS=$OPTARG ;;
      n) DRY_RUN=1 ;;
      h) usage; exit 0 ;;
      :) usage >&2; die "option -$OPTARG needs a value" ;;
      \?) usage >&2; die "unknown option -$OPTARG" ;;
    esac
  done
  shift $((OPTIND - 1))
  (( $# == 0 )) || die "unexpected argument: $1"

  [[ -n "$LOG_DIR" ]] || { usage >&2; die "-d DIR is required"; }
  [[ -d "$LOG_DIR" ]] || die "not a directory: $LOG_DIR"
  [[ "$DAYS" =~ ^[0-9]+$ ]] || die "DAYS must be a whole number, got: $DAYS"
}

Help goes to stdout with exit status 0 (the user asked for it); usage after an error goes to stderr with status 1. Validating DAYS as digits matters: it ends up inside a find argument, and you do not want arbitrary text there.

Step 4: Add logging

Why: when a scheduled job misbehaves, the log is all you have. Every line gets a timestamp, the script name and its process ID, and goes to stderr so it never mixes with real output. die logs and exits in one call.

log() {
  printf '%s %s[%d] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$SCRIPT_NAME" "$$" "$*" >&2
}

die() {
  log "ERROR: $*"
  exit 1
}

printf is used instead of echo because it behaves the same everywhere and never interprets backslashes or options in the message.

Step 5: Handle errors and exits with traps

Why: set -e stops the script but says nothing about where. A trap runs a command when the shell receives a signal or condition. Four traps cover the important cases:

  • ERR runs when a command fails under set -e; passing $LINENO tells you which line.
  • EXIT runs whenever the script ends, for any reason. It is the place to remove the lock and temporary files.
  • INT (Ctrl+C) and TERM (a plain kill, or systemd stopping the service) log the event and exit with the conventional codes 130 and 143, which then triggers the EXIT trap.
on_error() {
  local exit_code=$? line=$1
  log "ERROR: command failed with exit code $exit_code on line $line"
  exit "$exit_code"
}

trap 'on_error $LINENO' ERR
trap cleanup EXIT
trap 'log "interrupted"; exit 130' INT
trap 'log "terminated"; exit 143' TERM

exit_code=$? must be the first thing in on_error, before any other command overwrites $?.

Step 6: Add a dry run and handle any file name

Why: a dry run lets you see exactly what the script would do before it does it, both while developing and the first time it runs on a new server. Routing every changing command through one small run function means you cannot forget the check in one place.

run() {
  if (( DRY_RUN )); then
    log "DRY RUN: $*"
  else
    "$@"
  fi
}

while IFS= read -r -d '' file; do
  run gzip -- "$file"
  count=$((count + 1))
done < <(find "$LOG_DIR" -type f -name '*.log' -mtime +"$DAYS" -print0)

-print0 separates file names with a null byte, the only character that cannot appear in a path, and read -d '' splits on it. Together with IFS= and -r this handles spaces, newlines and backslashes in names. -- stops gzip from reading a file called -rf.log as options. The loop reads from a process substitution, not a pipe, so count is still set after the loop. One limitation: a failure inside <( ... ) does not trigger set -e, which is why the directory is validated up front.

Step 7: Prevent overlapping runs with a lock

Why: if one run is slow (a huge directory, a busy disk) the next scheduled run can start on top of it. mkdir is atomic: it either creates the directory or fails because it exists, even when two processes try at the same instant, and it works on every Unix-like system.

LOCK_DIR="${LOCK_DIR:-/tmp/${SCRIPT_NAME%.sh}.lock}"

take_lock() {
  if ! mkdir "$LOCK_DIR" 2>/dev/null; then
    die "another copy is already running (lock: $LOCK_DIR)"
  fi
  LOCK_ACQUIRED=1
}

cleanup() {
  if [[ -n "${LOCK_ACQUIRED:-}" ]]; then
    rmdir "$LOCK_DIR"
  fi
}

The LOCK_ACQUIRED flag makes sure a second copy that failed to get the lock does not delete the first copy’s lock on its way out. The EXIT trap removes the lock on normal exits, errors, Ctrl+C and kill; only kill -9 or a power loss can leave a stale lock, which you remove by hand after checking with ps that no copy is running.

On Linux, flock from util-linux is an alternative whose lock disappears automatically when the process dies: open a lock file on a spare file descriptor and run flock -n on it (-n fails immediately instead of waiting).

exec 9>/run/lock/compress-old-logs.lock
flock -n 9 || die "another copy is already running"

Step 8: The complete script

Here is everything together. This exact file was run for the outputs in the next step.

#!/usr/bin/env bash
set -Eeuo pipefail

readonly SCRIPT_NAME="${0##*/}"
LOG_DIR=""
DAYS=7
DRY_RUN=0
LOCK_DIR="${LOCK_DIR:-/tmp/${SCRIPT_NAME%.sh}.lock}"

usage() {
  cat <<USAGE
Usage: $SCRIPT_NAME -d DIR [-a DAYS] [-n] [-h]

Compress *.log files in DIR that are older than DAYS days.

  -d DIR    directory that holds the log files (required)
  -a DAYS   minimum age in days (default: $DAYS)
  -n        dry run: show what would happen, change nothing
  -h        show this help
USAGE
}

log() {
  printf '%s %s[%d] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$SCRIPT_NAME" "$$" "$*" >&2
}

die() {
  log "ERROR: $*"
  exit 1
}

on_error() {
  local exit_code=$? line=$1
  log "ERROR: command failed with exit code $exit_code on line $line"
  exit "$exit_code"
}

cleanup() {
  if [[ -n "${LOCK_ACQUIRED:-}" ]]; then
    rmdir "$LOCK_DIR"
  fi
}

parse_args() {
  local opt
  while getopts ':d:a:nh' opt; do
    case "$opt" in
      d) LOG_DIR=$OPTARG ;;
      a) DAYS=$OPTARG ;;
      n) DRY_RUN=1 ;;
      h) usage; exit 0 ;;
      :) usage >&2; die "option -$OPTARG needs a value" ;;
      \?) usage >&2; die "unknown option -$OPTARG" ;;
    esac
  done
  shift $((OPTIND - 1))
  (( $# == 0 )) || die "unexpected argument: $1"

  [[ -n "$LOG_DIR" ]] || { usage >&2; die "-d DIR is required"; }
  [[ -d "$LOG_DIR" ]] || die "not a directory: $LOG_DIR"
  [[ "$DAYS" =~ ^[0-9]+$ ]] || die "DAYS must be a whole number, got: $DAYS"
}

take_lock() {
  if ! mkdir "$LOCK_DIR" 2>/dev/null; then
    die "another copy is already running (lock: $LOCK_DIR)"
  fi
  LOCK_ACQUIRED=1
}

run() {
  if (( DRY_RUN )); then
    log "DRY RUN: $*"
  else
    "$@"
  fi
}

main() {
  trap 'on_error $LINENO' ERR
  trap cleanup EXIT
  trap 'log "interrupted"; exit 130' INT
  trap 'log "terminated"; exit 143' TERM

  parse_args "$@"
  take_lock

  log "start: dir=$LOG_DIR days=$DAYS dry_run=$DRY_RUN"
  local count=0 file
  while IFS= read -r -d '' file; do
    run gzip -- "$file"
    count=$((count + 1))
  done < <(find "$LOG_DIR" -type f -name '*.log' -mtime +"$DAYS" -print0)

  log "done: $count file(s) processed"
}

main "$@"

If ShellCheck is installed, run shellcheck compress-old-logs.sh as well as bash -n.

Step 9: Test every path before scheduling

Why: the failure paths are the ones that run at night when nobody is watching. Test each one on purpose:

./compress-old-logs.sh -h; echo "exit=$?"
./compress-old-logs.sh; echo "exit=$?"
./compress-old-logs.sh -d nope; echo "exit=$?"
./compress-old-logs.sh -d demo-logs -a abc; echo "exit=$?"
./compress-old-logs.sh -d demo-logs -x; echo "exit=$?"

Help exits with 0; each of the others prints an error such as ERROR: -d DIR is required, ERROR: not a directory: nope, ERROR: DAYS must be a whole number, got: abc or ERROR: unknown option -x, and exits with 1. Next, the dry run, which must not change anything:

./compress-old-logs.sh -d demo-logs -n
ls demo-logs
2026-10-15T09:54:07 compress-old-logs.sh[89435] start: dir=demo-logs days=7 dry_run=1
2026-10-15T09:54:07 compress-old-logs.sh[89435] DRY RUN: gzip -- demo-logs/app-10-days.log
2026-10-15T09:54:07 compress-old-logs.sh[89435] DRY RUN: gzip -- demo-logs/name with spaces.log
2026-10-15T09:54:07 compress-old-logs.sh[89435] DRY RUN: gzip -- demo-logs/app-20-days.log
2026-10-15T09:54:07 compress-old-logs.sh[89435] done: 3 file(s) processed

Simulate a running copy by creating the lock yourself, then remove it and do the real run:

mkdir /tmp/compress-old-logs.lock
./compress-old-logs.sh -d demo-logs; echo "exit=$?"
rmdir /tmp/compress-old-logs.lock
./compress-old-logs.sh -d demo-logs
ls demo-logs
... ERROR: another copy is already running (lock: /tmp/compress-old-logs.lock)
exit=1
... start: dir=demo-logs days=7 dry_run=0
... done: 3 file(s) processed
app-1-days.log
app-10-days.log.gz
app-20-days.log.gz
app-3-days.log
name with spaces.log.gz

Finally, force a command failure. Create an old log whose .gz already exists; gzip refuses to overwrite it and the ERR trap reports it. The lock is still removed afterwards (ls -d /tmp/compress-old-logs.lock reports no such file):

gzip: demo-logs/app-40-days.log.gz already exists -- skipping
... ERROR: command failed with exit code 1 on line 71

The gzip that failed ran inside the run function. With bash 3.2 on macOS the trap reported line 71, the line where run begins; newer bash versions may point at the failing command line itself, so treat the number as a starting point. Interrupting a long run with Ctrl+C or kill PID logs interrupted or terminated, exits with 130 or 143 and also removes the lock.

Step 10: Schedule it with cron or a systemd timer

Why: the script is only useful if it runs on its own. Install it somewhere stable first, for example sudo install -m 755 compress-old-logs.sh /usr/local/bin/, and do one manual dry run as the user that will run the job.

Option A: cron. Edit your crontab with crontab -e. The five fields are minute, hour, day of month, month and day of week. This runs every night at 02:15 and appends all output to a log file:

15 2 * * * /usr/local/bin/compress-old-logs.sh -d /var/log/myapp -a 7 >> "$HOME/compress-old-logs.log" 2>&1

Cron runs commands with /bin/sh and a minimal environment, so always use absolute paths. A % in a cron command means newline unless escaped as \%, which matters if you ever put date +%F directly in the crontab line. Check that it is installed with crontab -l.

Option B: a systemd timer (Linux). A timer starts a service of the same name. Create /etc/systemd/system/compress-old-logs.service:

[Unit]
Description=Compress old application logs

[Service]
Type=oneshot
User=appuser
ExecStart=/usr/local/bin/compress-old-logs.sh -d /var/log/myapp -a 7

and /etc/systemd/system/compress-old-logs.timer:

[Unit]
Description=Run compress-old-logs every night

[Timer]
OnCalendar=*-*-* 02:15:00
Persistent=true

[Install]
WantedBy=timers.target

Persistent=true runs a missed job right after boot if the machine was off at 02:15. Enable and inspect it:

Check before you run this

  • Runs with administrator rights. Make sure you understand every line.
sudo systemctl daemon-reload
sudo systemctl enable --now compress-old-logs.timer
systemctl list-timers compress-old-logs.timer
sudo systemctl start compress-old-logs.service
journalctl -u compress-old-logs.service -n 20

With systemd you do not need output redirection: everything the script writes to stderr ends up in the journal, timestamps included, and systemctl status shows whether the last run failed.

Check that it works

  1. bash -n compress-old-logs.sh prints nothing.
  2. A dry run lists exactly the files you expect and ls shows they are unchanged.
  3. A real run compresses only files older than the limit; newer files and files with spaces in their names are handled correctly.
  4. A second copy started while the lock exists exits with status 1 and a clear message, and no lock is left behind after any kind of exit.
  5. After scheduling, the cron log file or journalctl -u compress-old-logs.service shows a start: and a done: line for the first scheduled run.

If something goes wrong

  • The script works by hand but not from cron. Cron’s environment is minimal: different PATH, no aliases, /bin/sh. Use absolute paths, redirect output to a log as shown, and test with env -i /bin/sh -c '/usr/local/bin/compress-old-logs.sh -d ... -n'.
  • unbound variable errors. That is set -u doing its job: a variable is used before it is set. Fix the typo, or use ${VAR:-default} for genuinely optional variables.
  • “another copy is already running” but nothing is running. A previous run was killed with kill -9 or the machine lost power. Check with ps aux | grep compress-old-logs, then rmdir /tmp/compress-old-logs.lock.
  • Files you expected are not compressed. -mtime +7 counts whole 24-hour periods. Run the find command from the script by hand, and remember that copying files can reset their modification time.
  • The systemd service fails with a permission error. The User= in the service cannot write in the log directory. Check with sudo -u appuser test -w /var/log/myapp && echo writable.

Clean up

Warning: the commands below remove the scheduled job and delete the practice folder. Check what you are about to remove with crontab -l, systemctl list-timers and ls ~/script-lab first.

Check before you run this

  • Runs with administrator rights. Make sure you understand every line.
crontab -e    # delete the compress-old-logs line
sudo systemctl disable --now compress-old-logs.timer
sudo rm /etc/systemd/system/compress-old-logs.timer /etc/systemd/system/compress-old-logs.service
sudo systemctl daemon-reload
sudo rm /usr/local/bin/compress-old-logs.sh
rm -r ~/script-lab

Next steps

  • Add a -k DAYS option that deletes compressed logs older than a second limit, behind the same dry-run switch.
  • Write tests for the script with a framework such as bats, using a temporary directory per test.
  • Use logger to send log lines to syslog or the journal when the script runs from cron.
  • Compare with logrotate, which already does rotation and compression for many services, and decide when a custom script is really needed.
  • Run ShellCheck in CI on every shell script in your repository.

Official documentation

See also

  • 0058 Safe bash script template with set ‑euo pipefail and trap A starting point for bash scripts that stops on errors, rejects unset variables, cleans up temp files on exit and parses options.… Intermediate · Reference · Linux, macOS Script Shell scripting 15 Oct 2026
  • 0059 Incremental snapshot backups with rsync A bash script that copies a folder into dated snapshots with rsync, hard-links unchanged files to the previous snapshot to save space,… Advanced · How-to · Linux, macOS Script Backups & syncing · Shell scripting 15 Oct 2026
  • 0061 Fix: Address already in use (free a busy port) Your server cannot start because another process already listens on its port. This script shows which process holds the port and stops… Intermediate · Fix · Linux, macOS Script Processes & ports · Shell scripting 15 Oct 2026
  • 0108 List all Proxmox VE guests with status and locks (pvesh + jq) A Bash script that lists every VM and container in the cluster with node, status, lock, memory and uptime, using the Proxmox… Intermediate · Reference · Linux Script Shell scripting · VMs & containers 15 Oct 2026
  • 0109 A vzdump wrapper script with checks, retention and a log file A Bash wrapper around vzdump that checks the target storage first, backs up chosen guests or all guests with zstd and keep-last… Intermediate · How-to · Linux Script Backup & restore · Shell scripting 15 Oct 2026
  • 0164 systemd services: systemctl and journalctl basics systemd starts and supervises services on most Linux servers. systemctl starts, stops and enables them; journalctl shows their logs. Beginner · Concept · Linux Article Services & systemd 15 Oct 2026

Repos in the wild

History

Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.

  1. commitpedia-admin +19,395 chars

Report a problem

Something wrong, unsafe or copied? Tell the editors.
What is wrong?