What you'll learn
Quick Answer
Start every script with #!/usr/bin/env bash and set -euo pipefail, quote every variable expansion, and use [[ ]] for tests in bash. Read arguments with defaults such as ${2:-main} and required checks such as ${1:?usage}. Check exit codes rather than assuming success, clean up temporary files with trap, and run shellcheck before you trust the script with anything you cannot undo.
Variables and the quoting rule that saves you
A shell script is a text file of commands plus a shebang line telling the system which interpreter to use. Write #!/usr/bin/env bash rather than #!/bin/bash, because on macOS the bash in /bin is an old version and the one you installed lives elsewhere. Make it executable with chmod +x deploy.sh.
#!/usr/bin/env bash
name="Pune server"
echo "Deploying to $name"
echo "Log file: ${name}_deploy.log"
Assignment takes no spaces. name = "x" is not an assignment, it is an attempt to run a command called name, and the error message is unhelpful. Braces around ${name} are only needed when characters that could be part of a variable name follow it immediately.
Now the rule that matters more than every other rule in this article: quote every expansion. When you write $file unquoted, the shell splits the value on whitespace and then expands any * or ? it finds as a glob. A file called Sem 5 Notes.pdf becomes three arguments, and a command that meant to touch one file touches three that do not exist.
# wrong
cp $src $dest
rm -rf $BUILD_DIR/*
# right
cp -- "$src" "$dest"
rm -rf -- "${BUILD_DIR:?BUILD_DIR is not set}"/*
The second line is the famous one. If BUILD_DIR is empty or misspelled, rm -rf $BUILD_DIR/* expands to rm -rf /* and the shell carries it out without hesitation, because from its point of view you asked for exactly that. The ${VAR:?message} form makes the script abort with your message instead when the variable is unset or empty. Use it for every path you are about to delete.
One related habit: do not parse ls. for f in $(ls) breaks on any filename with a space. Use a glob, for f in *.log, which handles spaces correctly when the loop variable is quoted.
Conditionals and comparing things correctly
Bash has three testing constructs and beginners mix them up constantly. Use [[ ]] for strings and files, (( )) for arithmetic, and reserve [ ] for scripts that must run under plain POSIX sh.
config="/etc/priodemy/app.conf"
if [[ -f "$config" ]]; then
echo "Using $config"
elif [[ -d "$config" ]]; then
echo "$config is a directory, not a file" >&2
exit 1
else
echo "No config found at $config" >&2
exit 1
fi
The common file tests are -f for a regular file, -d for a directory, -e for exists at all, -s for exists and is non-empty, -r and -w for readable and writable, and -z / -n for an empty or non-empty string.
Numbers and strings compare differently and swapping them is a classic bug. Inside [[ ]], == compares strings, so [[ "09" == "9" ]] is false. For numbers use -eq -ne -lt -le -gt -ge, or the arithmetic form which reads better:
count=12
if (( count > 10 )); then
echo "too many"
fi
# string comparison, note the quotes on the right side
if [[ "$env" == "production" ]]; then
echo "be careful"
fi
Here is the trap inside the trap. In [[ ]], the right-hand side of == is treated as a glob pattern when it is unquoted. [[ "$file" == *.log ]] is a genuinely useful pattern match, but [[ "$a" == $b ]] where b happens to contain an asterisk will match things you never intended. Quote the right side unless you deliberately want pattern matching.
Finally, a portability point you must know before copying snippets from the internet. [[ ]] is a bash and zsh feature, not POSIX. On Debian and Ubuntu, /bin/sh is dash, so a script beginning with #!/bin/sh that uses [[ fails with a bare [[: not found. Either commit to bash in the shebang, or restrict yourself to [ ] with quoted variables everywhere.
Loops and script arguments
The for loop iterates over a list of words, most usefully a glob. Because a glob expands to actual filenames, spaces are handled correctly as long as you quote the loop variable when you use it.
for env in staging production; do
echo "Checking $env"
done
for f in ./logs/*.log; do
[[ -e "$f" ]] || continue # glob did not match anything
gzip -- "$f"
done
That continue guard exists because when a glob matches nothing, bash by default leaves the pattern itself in place, so $f becomes the literal string ./logs/*.log and your command runs against a file that does not exist. The alternative is shopt -s nullglob, which makes an unmatched glob expand to nothing at all.
To read a file line by line, the correct incantation is worth memorising exactly:
while IFS= read -r line; do
echo "host: $line"
done < hosts.txt
IFS= stops leading and trailing whitespace being stripped, and -r stops backslashes being interpreted as escapes. Without -r, a Windows path in your input file quietly loses its backslashes.
Arguments arrive as $1, $2 and so on, with $# as the count and "$@" as all of them. Always use "$@" and never $* when passing arguments on, because $* joins everything into one string and destroys the boundaries between them.
#!/usr/bin/env bash
set -euo pipefail
target="${1:?usage: deploy.sh <target> [branch]}"
branch="${2:-main}"
echo "Deploying branch $branch to $target"
echo "Extra args: $#"
Two expansions do most of the work here. ${2:-main} supplies a default when the argument is missing, and ${1:?message} aborts with your usage line when it is missing. Together they replace a dozen lines of manual validation, and they make the script self-documenting when someone runs it wrong.
Exit codes, set -e and why it is not enough
Every command returns an exit status: 0 means success, anything else means failure. $? holds the status of the last command. This is the only thing a script can use to know whether the previous step actually worked, and ignoring it is how a deploy script cheerfully copies an empty build directory over a working site.
if ! command -v rsync >/dev/null 2>&1; then
echo "rsync is not installed" >&2
exit 127
fi
if ! npm run build; then
echo "build failed, not deploying" >&2
exit 1
fi
The standard safety header is three options together:
set -euo pipefail
-e exits on an unhandled failing command, -u treats an unset variable as an error instead of an empty string, and -o pipefail makes a pipeline fail if any stage fails rather than only the last one. Without pipefail, curl bad-url | tee out.txt reports success because tee worked.
Now the part that matters, because set -e is far weaker than its reputation. It does not trigger for a command in an if condition, in a while condition, after !, or on the left of && and ||. That is by design, since those constructs are asking about the exit status. But it produces one genuinely surprising failure:
get_version() {
local version=$(cat missing-file.txt) # failure is swallowed
echo "$version"
}
Here local is itself a command, and it returns 0. The failure of cat is thrown away and set -e never fires. Declare and assign on separate lines: local version; version=$(cat file.txt). Command substitutions inside a larger string, such as echo "v$(cat missing)", hide failures the same way.
Treat set -euo pipefail as a seatbelt, not autopilot. Check the steps that must not fail explicitly, and clean up after yourself with trap, which runs on exit whether the script finished or died:
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
A backup script you would actually run
Everything above assembled into something small and genuinely useful: archive a directory, write it with a timestamped name, and keep only the seven most recent copies so the disk does not fill up silently.
#!/usr/bin/env bash
set -euo pipefail
SRC="${1:?usage: backup.sh <source-dir> <dest-dir>}"
DEST="${2:?usage: backup.sh <source-dir> <dest-dir>}"
KEEP=7
[[ -d "$SRC" ]] || { echo "source $SRC does not exist" >&2; exit 1; }
mkdir -p -- "$DEST"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
stamp="$(date +%Y%m%d-%H%M%S)"
archive="$DEST/backup-$stamp.tar.gz"
echo "Archiving $SRC ..."
tar -czf "$tmp/backup.tar.gz" -C "$SRC" .
# only publish the archive once tar has fully succeeded
mv -- "$tmp/backup.tar.gz" "$archive"
echo "Wrote $archive"
# delete everything older than the newest $KEEP archives
find "$DEST" -maxdepth 1 -type f -name 'backup-*.tar.gz' -printf '%T@ %p\n' \
| sort -rn \
| tail -n "+$((KEEP + 1))" \
| cut -d' ' -f2- \
| while IFS= read -r old; do
echo "Removing $old"
rm -f -- "$old"
done
Three decisions in there are worth copying into your own scripts. The archive is built in a temporary directory and only moved into place after tar succeeds, so an interrupted run never leaves a half-written file that looks like a valid backup. The trap removes the temporary directory even if the script dies at the tar line. And the destination path is validated before anything is deleted.
One portability warning. find -printf is a GNU extension, so this runs on Linux but not on macOS, whose BSD find lacks the flag. On macOS install GNU findutils or fall back to ls -1t "$DEST"/backup-*.tar.gz | tail -n +8, which is safe here only because the filenames are generated by this script and contain no spaces or newlines.
The same skeleton makes a deploy script: build, upload to a new timestamped release directory with rsync, then switch a current symlink to it in one atomic step. If the build fails, set -e stops before the symlink moves and the live site never sees the broken version.
Before trusting any script with destructive commands, run shellcheck script.sh. It is free, catches unquoted expansions, wrong test operators and the local trap above, and reviewers will not have to.
