A shell script is a saved sequence of commands, and the glue of server automation: backups, cleanups, deployments, health checks. Most script bugs come from unquoted variables and ignored errors, so this post starts with a safe header and builds up.
Versions in use on this date. bash 4.4 on Ubuntu 18.04.3 LTS (bash 4.2 on CentOS 7.7). ShellCheck, a linter that catches common mistakes, is in the Ubuntu 18.04 repositories as version 0.4.6.
The shape of a good script
flowchart TB
H[Shebang and strict mode] --> A[Parse arguments and check prerequisites]
A --> L[Take a lock so two copies cannot run]
L --> M[Main work in functions]
M --> E{Any command fails?}
E -->|yes| T[trap runs cleanup and logs error]
E -->|no| OK[Log success and exit 0]
T --> NZ[Exit non-zero so cron or CI notices]
The header every script needs
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
-estops on the first failing command.-utreats an unset variable as an error (it preventsrm -rf "$DIR/"with an emptyDIR).pipefailmakes a pipeline fail if any stage fails, not just the last.
Make it executable: chmod +x backup.sh, then run ./backup.sh.
Variables, quoting and arguments
name="web01"
today=$(date +%F) # command substitution
echo "Backing up ${name} on ${today}"
dest="/backup/${name}/${today}"
echo "first arg: $1, all: $*, count: $#"
: "${BACKUP_DIR:?BACKUP_DIR must be set}" # fail if unset
Always quote expansions ("$file"), otherwise a filename with a space becomes two arguments. Use ${var:-default} for defaults.
Conditions
if [[ -f "$file" ]]; then echo "exists"; fi
if [[ ! -d "$dir" ]]; then mkdir -p "$dir"; fi
if [[ "$USER" != "root" ]]; then echo "run as root" >&2; exit 1; fi
if [[ $count -gt 10 && "$env" == "prod" ]]; then ...; fi
if grep -q "ERROR" app.log; then echo "errors found"; fi # use the command's exit code
Prefer [[ ... ]] in bash over [ ... ]. Common tests: -f file, -d directory, -s non-empty, -z empty string, -n non-empty string.
Loops and functions
for host in web01 web02 db01; do
echo "Checking $host"
ssh "$host" uptime || echo "$host unreachable" >&2
done
while IFS= read -r line; do
echo "line: $line"
done < hosts.txt
log() { echo "$(date '+%F %T') $*" | tee -a /var/log/myscript.log; }
log "starting"
Reading files with while read -r handles spaces correctly, which for x in $(cat file) does not.
Exit codes, traps and cleanup
Every command returns an exit code: 0 means success; anything else is failure. $? holds the last one.
tmp=$(mktemp -d)
cleanup() { rm -rf "$tmp"; }
trap cleanup EXIT
trap 'echo "failed at line $LINENO" >&2' ERR
trap ... EXIT runs on success and on failure, so temporary files are always removed.
A complete, safe example
#!/usr/bin/env bash
set -euo pipefail
src="/var/www"; dest="/backup/www"; keep=7
lock=/var/lock/www-backup.lock
exec 9>"$lock"; flock -n 9 || { echo "already running" >&2; exit 1; }
stamp=$(date +%F)
mkdir -p "$dest"
tar -czf "$dest/www-$stamp.tar.gz" -C "$src" .
tar -tzf "$dest/www-$stamp.tar.gz" > /dev/null # verify the archive reads
find "$dest" -name 'www-*.tar.gz' -mtime +"$keep" -delete
echo "backup ok: www-$stamp.tar.gz"
Quality habits
- Run
shellcheck script.sh(install withapt install shellcheck) before every commit. - Test with
bash -n script.sh(syntax) andbash -x script.sh(trace). - Never put passwords in scripts or arguments; they appear in
ps. Read them from a protected file or the environment. - Send errors to stderr (
>&2) and keep stdout for results. - Use
command -v tool >/dev/null || { echo "tool missing"; exit 1; }to check dependencies. - Keep scripts in Git, and have them log what they do so cron failures can be investigated.