Every operations script should start with:
#!/usr/bin/env bash
set -euo pipefail| Flag | Behavior | Why |
|---|---|---|
set -e |
Exit on any non-zero return code | Stop before doing more damage on failure |
set -u |
Treat unset variables as errors | Catch typos in variable names |
set -o pipefail |
Pipeline returns first non-zero exit, not last | `false |
#!/usr/bin/env bash over #!/bin/bash: portable across systems where bash lives in different paths
(BSD, NixOS) and lets the user's PATH decide.
Validate everything that must be true before doing irreversible work.
# Required directory exists
if [ ! -d "$BACKUP_DIR" ]; then
echo "ERROR: backup directory ${BACKUP_DIR} does not exist" >&2
exit 1
fi
# Required tool is installed
if ! command -v snapraid &>/dev/null; then
echo "ERROR: snapraid not found in PATH" >&2
exit 1
fi
# Argument is one of the allowed values
case "${1:-}" in
sync|scrub) MODE="$1" ;;
*) echo "Usage: $0 sync|scrub" >&2; exit 1 ;;
esac| Pattern | Purpose |
|---|---|
command -v <tool> |
Returns 0 if <tool> is found in PATH, 1 otherwise — POSIX-portable, faster than which |
>&2 |
Redirect to stderr — error messages must not pollute stdout (which may be piped) |
${1:-} |
Default empty string if $1 is unset — works under set -u |
After doing the work, confirm the result before declaring success.
pg_dumpall | gzip > "$DUMP_FILE"
if [ ! -s "$DUMP_FILE" ]; then
echo "ERROR: dump file is empty: ${DUMP_FILE}" >&2
exit 1
fi| Test | Meaning |
|---|---|
[ -e "$f" ] |
Path exists |
[ -f "$f" ] |
Path is a regular file |
[ -d "$f" ] |
Path is a directory |
[ -s "$f" ] |
File exists AND is non-empty |
[ -r "$f" ] |
File is readable by current user |
[ -x "$f" ] |
File is executable |
Avoid the touch + chmod + chown dance:
# Less safe — file briefly exists with wrong perms between touch and chmod
touch /usr/local/sbin/myscript.sh
chmod 0750 /usr/local/sbin/myscript.sh
chown root:postgres /usr/local/sbin/myscript.sh
# Atomic — file is created with final mode/ownership in one syscall
install -m 0750 -o root -g postgres /dev/null /usr/local/sbin/myscript.shinstall -m -o -g <source> <dest> creates <dest> with the given mode/owner/group atomically.
Using /dev/null as source means "create the destination empty".
cat > /etc/systemd/system/myservice.service << UNIT
[Unit]
Description=My Service
After=network.target
[Service]
ExecStart=/usr/local/bin/myservice
User=myservice
Restart=on-failure
[Install]
WantedBy=multi-user.target
UNIT| Variant | Behavior |
|---|---|
<< UNIT |
Variables in the body are expanded (${VAR} interpolated) |
<< 'UNIT' |
Single-quoted delimiter — no expansion, body is literal |
<<- UNIT |
Strip leading TABs (only TABs, not spaces) — useful for indented heredocs |
Pick the variant by intent: if the unit must contain a literal $VAR, quote the delimiter.
TMP="$(mktemp)"
trap 'rm -f "${TMP}"' EXITmktemp creates a unique temp file in $TMPDIR (or /tmp) with safe permissions.
trap '<cmd>' EXIT runs <cmd> when the script exits — including on errors with set -e.
For directories: mktemp -d. To clean up a tree: trap 'rm -rf "${TMPDIR}"' EXIT.
case "${1:-}" in
sync)
snapraid sync
write_metric "$TEXTFILE_DIR/sync.prom"
;;
scrub)
snapraid scrub
write_metric "$TEXTFILE_DIR/scrub.prom"
;;
*)
echo "Usage: $0 sync|scrub" >&2
exit 1
;;
esacA single script with sub-commands is often cleaner than multiple separate scripts — shared setup runs once, the dispatch is explicit, and one cron/systemd entry per command.
shopt -s nullglob
for d in /mnt/smb/*; do
[[ -d "$d" ]] || continue
do_something "$d"
doneWithout nullglob: if /mnt/smb/* matches nothing, the loop runs once with $d set to the
literal string /mnt/smb/* — usually a bug. With nullglob, the loop simply doesn't execute.
timeout 3s ls -la "$d"/. >/dev/null 2>&1 || true| Suffix | Meaning |
|---|---|
3s |
seconds |
5m |
minutes |
1h |
hours |
timeout kills the command after the duration. The trailing || true swallows the timeout
exit code (124) so the outer script doesn't fail under set -e.
Use case: triggering an autofs mount where you want to fire-and-forget without blocking indefinitely if the mount target is dead.
su -s /bin/bash -c '<command>' <user>| Flag | Purpose |
|---|---|
-s /bin/bash |
Force a specific shell — works even if the user's login shell is /usr/sbin/nologin |
-c '<cmd>' |
Run <cmd> and exit, no interactive shell |
<user> |
Target user (must be after the flags) |
Use case: running php occ as www-data from a script that runs as root. The www-data user
often has nologin as login shell, so su www-data -c '...' fails — su -s /bin/bash works.
some_command 2>/dev/null || true| Component | Effect |
|---|---|
2>/dev/null |
Suppress stderr (the error message) |
|| true |
If the command fails, return 0 — don't abort the script under set -e |
Use case: a per-user loop where some users don't have the resource being processed. The check would be more code than just trying and ignoring failure.
$? holds the exit code of the last command in a pipeline, not the one you
usually care about. When you pipe a real command into a formatter, the formatter's
success masks the command's failure:
./validate-repo.sh | tail -8
echo "$?" # ← tail's exit code (~always 0), NOT the script'sBash records every pipe member's code in the PIPESTATUS array. Index 0 is the
first command:
./validate-repo.sh | tail -8
echo "${PIPESTATUS[0]}" # the script's real exit codeTwo ways to get the leftmost code reliably:
| Approach | Effect |
|---|---|
${PIPESTATUS[0]} |
Read the specific member's code. Must be used on the very next line — any other command overwrites the array. |
set -o pipefail |
Make the pipeline itself return the first non-zero code, so plain $? works. Changes behaviour for the whole script. |
pipefail is the right default in a script header. PIPESTATUS is what you reach
for in an ad-hoc one-liner where you piped through tail/grep/sed purely to
shorten output and still need the producer's status.
cmd || true (above) discards a failure so set -e won't abort. Sometimes you
need the opposite: keep running, but inspect the code and branch on it. A bare
assignment from a failing command still trips set -e; the && … || … idiom
neutralises it:
out="$(some-linter . 2>&1)" && rc=0 || rc=$?
if [[ "${rc}" -ne 0 ]]; then
echo "${out}" | sed 's/^/ /' # show what it said
ERRORS=$((ERRORS + 1)) # …and act on it
fiWhy it works: set -e does not trigger on a command that is the left side of &&
or || — the failure is considered "handled". && rc=0 runs on success, || rc=$?
captures the code on failure. Without this wrapper, out="$(some-linter .)" under
set -e would abort the whole script the moment the linter found something —
before you could print its output.
ansible-lint (exit 2 on findings) and any test runner (non-zero on failure) need
this when you call them from a larger orchestration script that must survive their
failure to report it.
apply_lxc() {
local ctid="$1"
local ip="$2"
echo "==> LXC${ctid}"
write_unit "$ip" | pct exec "$ctid" -- bash -c "cat > ${UNIT_PATH}"
pct exec "$ctid" -- systemctl daemon-reload
pct exec "$ctid" -- systemctl restart node_exporter
}
apply_ssh() {
local host="$1"
local ip="$2"
echo "==> ${host}"
write_unit "$ip" | ssh "$host" "cat > ${UNIT_PATH}"
ssh "$host" "systemctl daemon-reload && systemctl restart node_exporter"
}
# Drive the deployment
apply_lxc 200 "<ip-200>"
apply_lxc 210 "<ip-210>"
apply_ssh "storage" "<ip-storage>"Why functions: deploying the same change across LXCs (pct exec), VMs (ssh), and the host
(local execution) requires three different access methods. Functions abstract the access path
so the call sites stay readable.
local declares variables scoped to the function — without it, variables leak into the global scope.
Running a script twice should not produce different state than running it once.
# Bad — appends every run, eventually duplicates the line
echo "0 3 * * * /usr/local/sbin/pg-backup.sh" >> /etc/cron.d/pg-backup
# Good — overwrites with current desired state
echo "0 3 * * * /usr/local/sbin/pg-backup.sh" > /etc/cron.d/pg-backupFor richer idempotency, check before acting:
if ! grep -q "pg-backup.sh" /etc/cron.d/pg-backup 2>/dev/null; then
echo "0 3 * * * /usr/local/sbin/pg-backup.sh" >> /etc/cron.d/pg-backup
fiThis is also why Ansible exists: each module is idempotent by design.
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
DUMP_FILE="${BACKUP_DIR}/pg_dumpall_${TIMESTAMP}.sql.gz"| Format | Result |
|---|---|
%Y%m%d |
20260425 |
%Y%m%d_%H%M%S |
20260425_140530 |
%Y-%m-%dT%H:%M:%S%z |
2026-04-25T14:05:30+0200 (ISO 8601) |
Sortable by name (alphanumerical sort = chronological sort) — important for retention scripts
using find -mtime or ls -t.
SIZE="$(du -h "$FILE" | cut -f1)"
echo "wrote ${FILE} (${SIZE})"| Component | Effect |
|---|---|
du -h |
Disk usage in human-readable units (K, M, G) |
cut -f1 |
Take the first tab-separated field (the size) — drops the path |
For per-line processing where the inner work is more than a one-liner:
while read -r mdfile; do
while read -r link; do
# process each link
done < <(grep -oP '\]\(\K[^)]+' "${mdfile}")
done < <(find . -name "*.md" -type f)| Component | Effect |
|---|---|
read -r |
Don't interpret backslash escapes — preserves literal \n, \t in filenames |
< <(cmd) |
Process substitution — feed the output of cmd as stdin to the loop. Avoids subshell scoping issues that `cmd |
grep -oP '...\K...' |
Perl regex with \K: discard everything before \K from the match — used to extract just the captured part |
Use process substitution (< <(...)) over piping (| while) when the loop modifies variables —
piping creates a subshell where modifications are lost.