You already have daily-report.sh scheduled at 06:30 and service-status.sh every 10 minutes. This lesson is not cron again: it is the other half of the problem. Scheduling a script is easy; the hard part is that the script be written to run with nobody in front of it. A script designed for a person at a terminal assumes that somebody can answer a question, see a warning on screen, press Ctrl+C if it hangs and relaunch it if it fails. At 06:30 in the morning there is none of that. We are going to turn "runs on its own" into a list of seven concrete properties and implement them one by one until daily-report.sh is truly unattended.

Contents

  1. The seven properties of an unattended job
  2. Non-interactive: nothing to ask
  3. Idempotent: running it twice does no harm
  4. Exclusive: only one at a time
  5. Observable: reporting what it did
  6. Bounded and fail-safe
  7. Configurable: --dry-run and --force
  8. Notifying, not inheriting the environment and documenting
  9. Application: daily-report.sh ready for production

  1. The seven properties of an unattended job

Each property answers a question that only comes up when nobody is watching:

# Property Question it answers Tools
1 Non-interactive What if it asks something and nobody answers? Options, configuration, [[ -t 0 ]]
2 Idempotent What if it runs twice? mkdir -p, atomic writes, markers
3 Exclusive What if the previous run is still alive? flock
4 Observable How do I know tomorrow whether it worked? Exit codes, timestamped log
5 Bounded What if it hangs forever? timeout, limited retries
6 Fail-safe What if it fails halfway? set -euo pipefail, trap
7 Configurable How do I test it without breaking anything? --dry-run, --force

None of them is optional: an automated script that is missing one will sooner or later suffer exactly the incident that property prevented.

  1. Non-interactive: nothing to ask

The rule is absolute: an automated script asks nothing. Everything a human would decide arrives by one of three routes: command-line options (03-05) for what changes on each run, a configuration file (05-06) for what is stable on each machine, and environment variables for what the launcher injects (cron, systemd, a container). And a read in automation does not fail cleanly: it just waits. If standard input is closed, it returns an error and with set -e the script dies with an incomprehensible message; if there is something in it, it swallows it and carries on with an absurd value. The active defense is to detect that there is no terminal and refuse to ask:

confirm() {
    local answer
    [[ -t 0 ]] || { veloz_log_error "confirmation needed and there is no terminal; use --force"; return 1; }
    read -r -p "$1 [y/N] " answer
    [[ ${answer,,} == y ]]
}

[[ -t 0 ]] checks whether descriptor 0 is attached to a terminal (05-05). Under cron it is not, so the function returns 1 with a clear message instead of hanging. Check it yourself: [[ -t 0 ]] && echo yes || echo no answers yes in your terminal and no if you run it as echo | bash -c '…'. Its sibling [[ -t 1 ]] is for deciding whether to colorize the output: with colors in a log file you end up with \033[31m everywhere.

  1. Idempotent: running it twice does no harm

Idempotent means that running the operation once or five times leaves the system in the same state. It is the property that makes a retry safe, and in automation retries are constant: cron overlaps, somebody relaunches by hand after a failure, the daylight-saving change duplicates a run (07-01), a job dies halfway.

Not idempotent Idempotent Why
mkdir /srv/veloz/reports mkdir -p /srv/veloz/reports -p does not fail if it already exists
echo "$line" >> summary.txt printf '%s\n' "$line" > summary.txt >> duplicates on repeat
ln -s source target ln -sfn source target -f replaces the existing link

Four patterns solve almost everything. (a) Operations that are already idempotent by design: mkdir -p, rm -f, ln -sfn, install -d, rsync; always prefer them. (b) Check before acting, returning 0, not an error — "it was already done" is success, and a script that returns an error for that will raise a false alert every night. (c) Atomic write: temporary file plus mv, the most important of the four patterns:

[[ -f $dest ]] && { veloz_log_info "the report for $report_date already exists"; return 0; }   # (b)
tmp=$(mktemp "${dest}.XXXXXX") || veloz_die 1 "cannot create the temporary file"               # (c)
generate_report > "$tmp"
mv -f "$tmp" "$dest"        # atomic rename within the same filesystem

Writing straight onto $dest leaves it half-written if the script dies, and another process may read garbage. With a temporary file plus mv, the destination is either the complete old one or the complete new one, never a hybrid, because rename(2) is atomic. The condition is that the temporary file be on the same filesystem; that is why the mktemp uses the destination's directory and not /tmp. (d) Work-done markers for expensive tasks: a witness file ([[ -e $marker ]] && exit 0) that is created only on successful completion.

  1. Exclusive: only one at a time

You already saw flock in 05-02 and its use as a wrapper in the crontab (flock -n /var/lock/veloz.lock command). The question now is where to put it, and there are two answers: in the crontab, which is one line and does not touch the script; or inside the script itself, which protects it however it is launched — from cron, by hand, from a timer or from another script. For an important job, the second is the right one:

take_lock() {
    exec 9>/var/lock/veloz-report.lock || veloz_die 1 "cannot open the lock"
    flock -n 9 || { veloz_log_info "another run in progress; exiting without doing anything"; exit 0; }
}

Three details slip past people. exec 9> opens descriptor 9 for the whole life of the script, and the lock is released on its own when the process ends: you do not have to release it, not even in the trap. flock -n does not wait (with flock -w 30 9 you would wait 30 seconds in case the previous run is finishing). And here it exits with exit 0, not with an error, because "there is already a run in progress" is normal behavior, not a fault worth an alert. The lock file goes in /var/lock or /run/lock, never inside the data directory the job manipulates.

  1. Observable: reporting what it did

If tomorrow you cannot answer "did it run, how long did it take and what did it do?" by looking at a file, the job is not observable. Three things are needed. Meaningful exit codes, picking up the table from 05-03: 0 is all good — including "it was already done" —, 64 usage error, 65 corrupt input data, 69 service unavailable, 75 temporary failure and 78 configuration error. That way the caller decides without reading the text: 75 is retried with backoff, 64 and 78 have to be fixed by hand.

Messages with a timestamp and a level, because echo "starting" is useless in a three-month log:

veloz_log() { printf '%s [%s] %s\n' "$(date -Is)" "$1" "${*:2}" >&2; }
veloz_log INFO "processed 1284 shipments"     # 2026-08-03T06:30:04+02:00 [INFO] processed 1284 shipments

date -Is gives the ISO-8601 timestamp with time zone, which sorts correctly alphabetically and is unambiguous. The full design of logging — levels, thresholds, component, rotation — is the subject of 07-04. And a readable final summary: the last line must let you judge the run at a glance (END ok: 1284 shipments, 37 issues, 12s, output $dest).

  1. Bounded and fail-safe

A job that hangs is worse than one that fails: it does not warn you, it holds the lock, it blocks the following ones and it piles up processes for days. Everything that talks to the outside world needs a time limit, at two complementary levels: timeout 30s curl … inside the script to cut the specific operation with a code you can handle, and timeout 300s /path/script.sh from the crontab as a safety net. timeout returns 124 when it cuts, and your script must tell that apart from a normal failure. Retries, moreover, are limited and with backoff, picking up 05-03:

retry() {                            # retry N command...
    local attempts="$1" delay=2 i; shift
    for (( i = 1; i <= attempts; i++ )); do
        "$@" && return 0
        (( i < attempts )) && { sleep "$delay"; delay=$(( delay * 2 )); }
    done
    veloz_log ERROR "exhausted the $attempts attempts at: $*"; return 75
}

Three things make it correct: there is a maximum number of attempts (a while true retrying forever is another way of hanging), the wait doubles so as not to hammer a service that is already suffering, and the final failure returns 75 (EX_TEMPFAIL), which tells the caller "this may be transient".

The Module 5 header is still the foundation — set -euo pipefail — and guaranteed cleanup with trap matters twice as much here: nobody is going to delete by hand the orphaned 400 MB temporary file.

WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/report.XXXXXX")
cleanup() {
    local code=$?; rm -rf "$WORKDIR"
    (( code == 0 )) && veloz_log INFO "END ok" || veloz_log ERROR "END with error ($code)"
    exit "$code"
}
trap cleanup EXIT

# RECOVERABLE error declared explicitly: the 'if !' neutralizes set -e for that command
if ! metrics=$(veloz_api_get /metricas); then
    veloz_log WARN "no metrics; the report will go out without the performance section"
    metrics='{}'; (( ++WARNINGS ))
fi

What separates a mature automated script from a fragile one is telling the recoverable error apart from the fatal one. Fatal: the shipments CSV is missing, the output directory does not exist, jq is missing → log it, exit with a code and do not continue. Recoverable: a malformed CSV line, the API not answering /metricas → log it as WARN, count it, carry on and reflect it in the summary. Since set -e aborts on any failure, recoverable ones have to be declared, and the if ! is the way to do it (05-03). A script that aborts because it could not read an optional metric is as bad as one that carries on with a corrupt CSV: the decision is yours, command by command.

  1. Configurable: --dry-run and --force

A job that deletes, moves or publishes things must be able to run for real but pretend. --dry-run lets you roll out a new job, verify a configuration change or understand what it would do today, with no consequences. The clean implementation is not to fill the code with ifs, but a prefix:

DRY_RUN=0; RUN=()                             # empty array: prepends nothing
(( DRY_RUN )) && RUN=(echo "[DRY-RUN]")
"${RUN[@]}" mv -f "$tmp" "$dest"              # every action WITH AN EFFECT carries the prefix
"${RUN[@]}" rm -f "$old"

In normal mode RUN is empty and the expansion disappears completely, so mv … runs as it is. In dry-run mode it expands to echo "[DRY-RUN]" mv …, which prints the command ([DRY-RUN] mv -f /tmp/report.a3Kx9 /srv/veloz/reports/report-2026-08-02.txt) instead of running it. It is the array idiom from 04-03, and the rule is: only commands with an effect carry the prefix; reading, counting and computing happen the same way in both modes, so that the simulation is realistic. --force is the complement: it skips checks and confirmations, and it is what gives the confirm function from section 2 a way out — with no terminal and no --force it refuses; with --force it carries on, never the other way around.

  1. Notifying, not inheriting the environment and documenting

Notifying. Nobody reads logs that are not failing. For a failure to reach a human there are three routes: mail (non-empty output plus MAILTO in cron, 07-01), logger to syslog (logger -t veloz-report -p user.err "…"), and a status file that another system reads, which is the most versatile and fits with 06-05:

jq -n --arg when "$(date -Is)" --argjson code "$code" --argjson warnings "$WARNINGS" \
      '{task:"daily-report", when:$when, code:$code, warnings:$warnings}' > "$BASE/logs/report-status.json"

That file turns "the job worked" into a queryable fact: the watchdog from 07-04 can warn you if the last successful run is more than 26 hours old. It is the only way to detect a job that did not run, something a log will never tell you because there is no line to write when nothing happens. Not inheriting the environment. You already saw in 07-01 that cron gives a minimal PATH; the design conclusion goes further: an automated script must not trust what it inherits, whether it comes from cron, from systemd or from a remote ssh (07-06). You protect yourself in four lines at the top:

export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export LC_ALL=C                       # predictable comparisons and sorting (06-03)
umask 027                             # consistent permissions on whatever it creates (02-03)
veloz_require jq awk curl flock       # fail at the start, not halfway (05-06)

Setting PATH in the script and not in the crontab has one advantage: the guarantee travels with it and works the same from a timer or over SSH.

Documenting. When the job fails it will be 3 in the morning and the person looking will not be you. The minimum runbook is four answers in the script's header, and it takes five minutes to write:

# WHAT IT DOES: aggregates the previous day's /srv/veloz/data/shipments.csv and publishes the report.
# WHEN:      every day at 06:30 ('veloz' crontab on srv-veloz-01).
# LOG:       ~/veloz-ops/logs/report.log  |  status: logs/report-status.json
# IF IT FAILS: relaunching with --date YYYY-MM-DD is safe (it is idempotent).
#            Code 65 = corrupt CSV, 69 = API down, 64 = bad invocation.

  1. Application: daily-report.sh ready for production

The complete automation skeleton; you already have the awk and jq logic from Module 6:

#!/usr/bin/env bash
# (the runbook block from section 8 goes here)
set -euo pipefail
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export LC_ALL=C; umask 027

readonly BASE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
. "$BASE/lib/common.sh"
[[ -r $BASE/etc/veloz-ops.conf ]] && . "$BASE/etc/veloz-ops.conf"
REPORT_DATE=$(date -d yesterday +%F); DRY_RUN=0; FORCE=0; RUN=(); WARNINGS=0

parse_options() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -n|--dry-run) DRY_RUN=1 ;;
            -f|--force)   FORCE=1 ;;
            --date)       REPORT_DATE="${2:?missing the date}"; shift ;;
            *)            veloz_die 64 "unknown option: $1" ;;
        esac
        shift
    done
    [[ $REPORT_DATE =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || veloz_die 64 "invalid date: $REPORT_DATE"
    (( DRY_RUN )) && RUN=(echo "[DRY-RUN]")
}

main() {
    parse_options "$@"
    veloz_require awk jq curl
    exec 9>/var/lock/veloz-report.lock
    flock -n 9 || { veloz_log_info "another run in progress"; exit 0; }
    WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/report.XXXXXX"); trap 'rm -rf "$WORKDIR"' EXIT
    local dest="$REPORT_DIR/report-$REPORT_DATE.txt"
    [[ -f $dest && $FORCE -eq 0 ]] && { veloz_log_info "report for $REPORT_DATE already done"; return 0; }
    veloz_log_info "START report for $REPORT_DATE (dry_run=$DRY_RUN)"
    [[ -r $CSV_PATH ]] || veloz_die 65 "cannot read $CSV_PATH"
    if ! METRICS=$(timeout 20s veloz_api_get /metricas); then
        veloz_log_error "no metrics from the API; continuing without that section"
        METRICS='{}'; (( ++WARNINGS ))
    fi
    generate_report "$REPORT_DATE" > "$WORKDIR/report.txt"   # awk, from Module 6
    "${RUN[@]}" mkdir -p "$REPORT_DIR"
    "${RUN[@]}" mv -f "$WORKDIR/report.txt" "$dest"
    publish_summary_json "$REPORT_DATE" "$METRICS" "$WARNINGS"   # jq -n --arg, from Module 6
    veloz_log_info "END ok: report for $REPORT_DATE in $dest ($WARNINGS warnings)"
}

main "$@"

Go through the list looking for the seven: non-interactive (everything by options and configuration, no read), idempotent (mkdir -p, the $dest check that returns 0, temporary file plus atomic mv), exclusive (lock on descriptor 9), observable (START and END, codes 64/65, JSON summary), bounded (timeout 20s on the API), fail-safe (set -euo pipefail, trap, unreadable CSV fatal versus missing metrics recoverable) and configurable (--dry-run, --force, --date).

The final crontab line is shorter than the one in 07-01, because the script already protects itself, and now it is possible to test before installing with ~/veloz-ops/bin/daily-report.sh --dry-run --date 2026-08-02:

30 6 * * * /usr/bin/timeout 900s /home/veloz/veloz-ops/bin/daily-report.sh >> /home/veloz/veloz-ops/logs/report.log 2>&1

Common Mistakes and Tips

  • Leaving a read "just in case". In automation it hangs the process or returns garbage. Everything by options, configuration or environment.
  • Confusing "it was already done" with an error. Returning a non-zero code raises a false alert every night and people stop looking at them.
  • Writing straight onto the final file, or putting the temporary file in /tmp. The first leaves the destination corrupt if it fails halfway; the second stops the mv from being atomic, because it copies and deletes across different disks.
  • flock without -n in a periodic job. Runs pile up waiting instead of being discarded.
  • Retrying with no limit and no backoff, or calling without timeout. Both are ways of hanging forever, and the first also hammers a service that is already down.
  • A --dry-run that does not fully simulate, or colors in the log. If a command with an effect sneaks in without the prefix the simulation lies; and without deciding the color with [[ -t 1 ]] you will end up with escape sequences in a three-month file.
  • Tip: before scheduling a new job, run it three times in a row by hand. If the second and third are not harmless, it is not idempotent yet and it is not ready for cron.

Exercises

Exercise 1. This fragment is meant to publish the day's summary. Point out the three problems that prevent it from running unattended and rewrite it.

read -p "Date to process: " report_date
mkdir /srv/veloz/reports/$report_date
curl http://localhost:8080/metricas > /srv/veloz/reports/$report_date/metrics.json

Exercise 2. Write a veloz_write_atomic function for lib/common.sh that takes the destination path, reads the content from standard input and publishes it atomically, honoring dry-run mode through the DRY_RUN variable.

Solutions

Solution 1. The three problems: (a) the read hangs the script when there is no terminal; (b) mkdir without -p fails on the second run and curl without -sSf or timeouts may hang or save an error page as if it were JSON; (c) it writes straight to the destination, so a failure halfway leaves a truncated JSON. Besides, the unquoted variable would break with any value containing spaces (03-06).

report_date="${1:-$(date -d yesterday +%F)}"
[[ $report_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || veloz_die 64 "invalid date: $report_date"
dest="/srv/veloz/reports/$report_date"; mkdir -p "$dest"
tmp=$(mktemp "$dest/metrics.XXXXXX") || veloz_die 74 "no temporary file"
trap 'rm -f "$tmp"' EXIT
timeout 30s curl -sSf --connect-timeout 5 --max-time 25 http://localhost:8080/metricas > "$tmp" ||
    { veloz_log_error "no metrics for $report_date"; exit 69; }
mv -f "$tmp" "$dest/metrics.json"

The date now arrives as an argument with a sensible default (${1:-…} from 03-06), it is validated with =~ (05-04), and the trap guarantees that no temporary files are left behind even if the curl fails.

Solution 2.

# veloz_write_atomic — publishes stdin to a file without ever leaving it half-written.
# Usage: generate_something | veloz_write_atomic /path/dest
veloz_write_atomic() {
    local dest="${1:?missing the destination}" dir tmp
    dir=$(dirname -- "$dest")
    [[ -d $dir ]] || mkdir -p "$dir" || { veloz_log_error "cannot create $dir"; return 74; }
    if (( ${DRY_RUN:-0} )); then
        veloz_log_info "[DRY-RUN] would write $(wc -c) bytes to $dest"; return 0
    fi
    tmp=$(mktemp "$dest.XXXXXX") || { veloz_log_error "no temporary file next to $dest"; return 74; }
    cat > "$tmp" && mv -f "$tmp" "$dest" && return 0
    rm -f "$tmp"; veloz_log_error "failed to publish $dest"; return 74
}

The mktemp is created next to the destination ("$dest.XXXXXX") and not in /tmp, which is the condition for the mv to be an atomic rename and not a copy across disks. In dry-run mode it consumes the input anyway with wc -c so as not to break the pipeline feeding it: if you did not read stdin, the process on the left would get a SIGPIPE. And on any failure the temporary file is deleted, leaving the previous destination intact.

Conclusion

A script ready to run on its own satisfies seven properties. It is non-interactive: zero reads, everything by options, configuration or environment, and [[ -t 0 ]] to refuse to ask when there is nobody there. It is idempotent: mkdir -p, check before acting returning 0 when it was already done, write to a temporary file and publish with an atomic mv, and markers for expensive work. It is exclusive: flock -n as a wrapper in the crontab and, better, with exec 9> inside the script so that it is protected however it is launched. It is observable: codes that distinguish types of failure, lines with date -Is and a level, and a final summary you understand at a glance. It is bounded: timeout at two levels on everything that talks to the outside world, and retries with a maximum and backoff. It is fail-safe: set -euo pipefail, a cleanup trap and a conscious decision, command by command, about which error is fatal and which is recoverable. And it is configurable: --dry-run with the "${RUN[@]}" prefix to test it with no consequences, and --force to skip the confirmations that in automation nobody can answer. On top of that, two things that are not code: not inheriting the environment — set PATH, LC_ALL and umask, and check the tools at the start — and writing the minimum runbook in the header.

With the unattended-job technique in hand, in 07-03 we apply it to the operation you are most grateful to have automated and that goes worst when improvised: the backup. You will see what to back up and what not, the 3-2-1 rule, the difference between full, incremental and differential copies, rsync --link-dest for incremental copies that restore like full ones, retention policies, verification with sha256sum — because an unverified backup is not a backup — and, above all, the part almost nobody tests until they need it: the restore. ~/veloz-ops/bin/backup.sh is born.

Bash Programming Course

Module 1: Introduction to Bash

Module 2: Basic Bash Commands

Module 3: Scripting Fundamentals

Module 4: Intermediate Scripting

Module 5: Advanced Scripting Techniques

Module 6: Working with External Tools

Module 7: Automation and Scheduling

Module 8: Best Practices and Optimization

Module 9: Real-World Projects

© Copyright 2026. All rights reserved