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.

Frequently Asked Questions

Bash or Python for scripting? Use bash when the job is mostly running other programs and moving files around, because that is what the shell syntax is built for and it is present on every Linux box without installing anything. Switch to Python once you need real data structures, JSON parsing, error handling with retries, or arithmetic beyond integers. A rough line: if the script passes a hundred lines or grows nested conditionals, it wants to be Python.
Why does my script fail with a bad interpreter error? Almost always Windows line endings. A file edited on Windows ends each line with carriage return plus newline, so the shebang becomes /usr/bin/env bash followed by an invisible carriage return, and no such interpreter exists. Fix it with dos2unix script.sh, or configure your editor to save with LF endings and set core.autocrlf appropriately in git.
What is the difference between source script.sh and ./script.sh? ./script.sh runs the file in a new child shell, so any variables it sets or directories it changes into disappear when it exits. source script.sh, also written as . script.sh, runs the commands in your current shell, which is why activating a Python virtual environment requires source. It also means a sourced script calling exit will close your terminal session.
Can I run bash scripts on Windows? Yes, through WSL, Git Bash or a container, and WSL is closest to a real Linux environment. Be aware that paths differ, that file permissions behave differently on mounted Windows drives, and that tools such as GNU find and sed have different flags on other platforms. If the script is going to run on a Linux server, test it in WSL or a container rather than on Git Bash.
How do I schedule a script to run automatically? On Linux, cron is the usual answer: crontab -e and a line such as 0 2 * * * /home/user/backup.sh /var/www /backups. Two things catch everyone out. Cron runs with a minimal environment and a different PATH, so use absolute paths for every command and every file. And it does not run interactively, so redirect output to a log file, otherwise failures are mailed to a local mailbox nobody reads.