Bash Scripting for Automation
Stop typing the same ten commands forever — write them down once and let the machine do the work. Learn how to build reliable bash scripts with shebangs, variables and arguments, conditionals and loops, functions and exit codes, plus the production-grade patterns (set -euo pipefail) that keep automation from failing silently.
What is this?
Bash scripting is writing sequences of shell commands into a file that the shell can execute on demand — from a two-line backup helper to a full deployment pipeline. A script is just a program: it takes arguments, reads variables, makes decisions with conditionals, repeats work with loops, groups logic into functions and reports success or failure through its exit code.
Why do you need it?
- Anything you type more than once deserves a script: backups, deploys, log rotation, environment setup and smoke tests.
- Scripts are the glue between tools — bash sits at the heart of CI runners, cron jobs, Dockerfile RUN steps and Ansible shells.
- A script encodes tribal knowledge: the exact sequence of commands that works is preserved, reviewable and repeatable.
- Bash needs no runtime, no compiler and no dependencies beyond the shell — it runs on every Linux machine, container and cloud VM.
- A small investment in disciplined patterns (set -euo pipefail, exit codes) turns fragile one-offs into reliable automation that fails loudly instead of silently.
Mental model
A checklist a pilot reads aloud on every flight
A bash script run by cron every night
The checklist is the same each time: verify, act, verify, log — and if any step fails, stop and escalate. That is exactly what a hardened bash script does with conditionals, exit codes and set -e: it never skips a step silently, and it tells you loudly when something is wrong.
How it works
- The shebang (#!/usr/bin/env bash) tells the kernel which interpreter to run the file with, so ./script.sh works without typing bash explicitly.
- Bash executes the file line by line in a child shell, keeping variables and working directory changes isolated from your interactive session.
- Positional parameters ($1, $2, ...) map to the arguments you pass on the command line, and $? always holds the exit status of the last command.
- Conditionals (if/test) and loops (for/while) are syntax that controls the order and repetition of command execution.
- An exit code of 0 reports success; any non-zero value reports failure. set -euo pipefail makes the script terminate automatically when a command or pipeline fails.
How a bash script executes from invocation to exit status.
Key commands
bash -n deploy.shSyntax-check a script without executing it. No output means the syntax is valid.
(no output)
echo "$HOME"Print the value of a variable. Quoting prevents word-splitting and globbing.
/home/dev
[ -f /etc/passwd ] && echo "passwd exists"The test builtin checks a condition; && runs the echo only if the test succeeds.
passwd exists
for i in {1..3}; do echo "item $i"; doneA for loop over a brace-expanded range, printing each value.
item 1 item 2 item 3
set -u; echo "$UNDEFINED"With -u (nounset) set, referencing an unset variable aborts the shell.
set -o pipefail; false | echo "after pipe"Without pipefail the pipeline succeeds; with it, the pipeline inherits the failure of false.
Examples
A deployment script that fails loudly instead of silently
#!/usr/bin/env bashset -euo pipefail APP_DIR="${1:-/opt/myapp}"BACKUP_DIR="${2:-/var/backups}"NOW="$(date +%Y%m%d-%H%M%S)" backup() { mkdir -p "$BACKUP_DIR" cp -r "$APP_DIR" "$BACKUP_DIR/$APP_DIR-$NOW" echo "backup created at $BACKUP_DIR/$APP_DIR-$NOW"} deploy() { mkdir -p "$APP_DIR" echo "release $(date +%H%M)" > "$APP_DIR/release.txt" echo "deployed release $(date +%H%M) to $APP_DIR"} for service in web worker; do systemctl is-active --quiet "$service" || { echo "$service is not running - aborting" >&2 exit 1 }done backupdeployecho "deploy script finished"set -euo pipefail turns silent failures into hard stops: an undefined variable aborts (u), a failed command aborts (e) and a failed command inside a pipeline aborts (pipefail). Arguments fall back to defaults with ${1:-...}, the for loop gates the deploy on both services being active, and the explicit exit 1 hands a clear signal to cron or CI.
In production
- Deploy and release scripts: build, copy artifacts, run migrations, restart services — executed by cron or CI runners.
- Backup and rotation jobs that tar a directory, upload it and clean up old copies, always logging and exiting non-zero on failure.
- Health-check and self-healing wrappers: restart a service if a probe fails, capped with retry limits.
- Environment bootstrap scripts (provisioning) that install packages, create users and apply config on fresh VMs.
- Database maintenance and ETL glue — dump, transform with awk/sed, and load — glued together where heavy tools are overkill.
Best practices
- Start every script with #!/usr/bin/env bash and set -euo pipefail.
- Quote every variable expansion: "$VAR" — unquoted expansions silently split and glob.
- Use ${VAR:-default} for optional inputs and validate required arguments at the top of the script.
- Prefer clear names and functions for logic that is used more than once; keep each function doing one job.
- Always exit non-zero on failure and write a meaningful message to stderr with >&2.
- Run shellcheck on every script in CI — it catches quoting, POSIX and syntax bugs a human misses.
Common mistakes
- Spaces around = in assignments: name = "alice" runs the command name, not an assignment.
- Forgetting to make the script executable, then failing with ./script.sh: Permission denied.
- Using $@ unquoted, which splits arguments; use "$@" to preserve each argument as one word.
- Writing [ "$x" = "y" ] with && inside [ ], which is invalid — use [ "$x" = "y" ] && ... outside.
- Relying on set -e alone while a failing command is hidden inside a pipeline — add set -o pipefail.
- Not redirecting cron script output, so errors vanish into cron mail and nobody reads them.
Common issues
./deploy.sh: Permission deniedThe file is not executable, or the filesystem is mounted noexec.
chmod +x deploy.sh, or run bash deploy.sh. For noexec mounts, run from a normal directory.
deploy.sh: line 3: $APP_DIR: unbound variableset -u is active and APP_DIR was never assigned before use.
Assign defaults with "${1:-/opt/myapp}" or set APP_DIR before it is referenced.
The script continues even though a command failed inside a pipelineset -e does not catch failures of commands that are not the last in a pipeline.
Add set -o pipefail so the pipeline fails if any stage fails.
Debugging tips
- bash -n script.sh checks syntax only; bash -x script.sh traces every command and its expansion.
- Add set -x temporarily inside the script to see values as they are evaluated, then remove it.
- echo "DEBUG: VAR=$VAR" before and after the suspect block to see where the value changes.
- Run shellcheck and fix every warning before debugging behavior — most bash bugs are lint-visible.
- Reduce: isolate the failing line in a tiny test script, then grow it back one line at a time.
Interview checkpoint
Hands-on lab
- Create a bash script with a shebang, variables and positional arguments
- Use a conditional and a loop inside the script
- Make the script executable and run it both ways
- Add set -euo pipefail and confirm the script stops on failure
Write, harden and run your first automation script
15 min · intermediate
Quick quiz
What is the purpose of the shebang line #!/usr/bin/env bash?
Which test checks whether a file exists in a bash conditional?
With set -e active, a script exits immediately the moment any command returns a non-zero status.
In echo "${NAME:-world}", what is printed when the variable NAME is unset?
You run ./deploy.sh prod api. Inside the script, what does $1 hold?
Your script runs curl | jq and the jq step fails, but set -e never triggers and the script keeps going. Which single line fixes this?
Cheat sheet
#!/usr/bin/env bashShebang — run this file with bashbash script.shRun a script explicitlybash -n script.shSyntax check without runningbash -x script.shTrace every executed linechmod +x script.shMake a script executablename="alice"Assign (no spaces around =)echo "$name"Use a variable (quote it!)$1 $2 $3Positional arguments$@ / $#All arguments / count of arguments$?Exit status of the last command${var:-default}Fallback value if emptyif [ -f "$f" ]; then ...Test a file exists[ "$x" = "y" ]String equality test[ "$n" -gt 5 ]Numeric greater-than testfor i in {1..10}; do ...; doneIterate a rangewhile read -r line; do ...; done < fileRead a file line by lineset -euo pipefailExit on error, unset var, pipe failureset -xDebug trace outputexit 1Return a non-zero statuslocal x=1Scoped variable inside a functiontrap "cleanup" EXITRun cleanup on exit