From typing commands to writing safe, reusable automation. Bash is the glue of DevOps — every CI step, entrypoint script, and 3 a.m. one-liner is Bash. This guide takes you from variables and pipes to robust scripts with error handling that won't silently corrupt production. Work top to bottom, or jump to a part.
The journey:
| You need | Why |
|---|---|
| A Bash shell (Linux, macOS, or WSL) | Every example runs here |
| Basic command-line comfort | You should know cd, ls, cat |
If the terminal itself is new, do the Linux tutorial first. Confirm your shell: bash --version (aim for 4+; macOS ships an old 3.2 — brew install bash for a modern one).
A script is a text file of commands. The first line — the shebang — tells the OS which interpreter to use:
#!/usr/bin/env bash
echo "Hello, $USER"
chmod +x hello.sh # make it executable
./hello.sh # run it
bash hello.sh # or run without the +x
Use #!/usr/bin/env bash (not #!/bin/bash) so it finds Bash wherever it's installed. sh is not Bash — it's a stricter POSIX shell, so a script that works with bash script.sh may break with sh script.sh.
name="Ada" # no spaces around =
greeting="Hello, $name" # expansion happens in double quotes
literal='Hello, $name' # single quotes = no expansion
count=$(ls | wc -l) # command substitution
Quoting is the #1 source of bugs. Always double-quote variable expansions so spaces and empty values don't break your commands:
file="my report.txt"
rm $file # WRONG: deletes "my" and "report.txt"
rm "$file" # right: one file, spaces intact
Useful parameter expansions:
${VAR:-default} # use 'default' if VAR is unset/empty
${VAR:?error msg} # exit with error if VAR is unset (great for required args)
${file%.txt} # strip a suffix
${file##*/} # basename (strip everything up to last /)
Every command returns an exit code: 0 = success, non-zero = failure. It's how scripts make decisions.
if grep -q "ERROR" app.log; then
echo "Errors found"
else
echo "Clean"
fi
command && echo "ok" # run second only if first succeeded
command || echo "failed" # run second only if first failed
[[ -f "$file" ]] # true if file exists
[[ -z "$str" ]] # true if string is empty
[[ "$a" == "$b" ]] # string compare
(( n > 5 )) # arithmetic compare
Prefer [[ ... ]] (Bash) over [ ... ] (POSIX) — it's safer with unquoted variables and supports &&, ||, and pattern matching. Check $? for the exit code of the last command when you need it explicitly.
for f in *.log; do
echo "Processing $f"
done
for i in {1..5}; do echo "$i"; done
while read -r line; do # -r stops backslash mangling
echo "Line: $line"
done < input.txt
# Loop over command output safely (handles spaces/newlines):
while IFS= read -r file; do
echo "$file"
done < <(find . -name "*.conf")
Never for line in $(cat file) — it splits on spaces, not lines, and mangles filenames with spaces. Use while IFS= read -r as above.
#!/usr/bin/env bash
log() { # $1, $2… are the function's args
echo "[$(date +%H:%M:%S)] $*"
}
deploy() {
local env="$1" # 'local' keeps it out of global scope
[[ -n "$env" ]] || { echo "usage: deploy <env>"; return 1; }
log "Deploying to $env"
}
deploy "$@" # forward the script's args to the function
Key pieces: $1–$9 are positional args, $@ is all of them (quote it: "$@"), $# is the count, $0 is the script name. Always declare function-local variables with local so they don't leak.
cmd > out.txt # stdout to file (overwrite)
cmd >> out.txt # append
cmd 2> err.txt # stderr to file
cmd > all.txt 2>&1 # both streams to one file
cmd &> all.txt # same, Bash shorthand
cmd < in.txt # stdin from file
a | b | c # pipe a's stdout into b's stdin, etc.
diff <(sort f1) <(sort f2) # process substitution: treat output as a file
Understanding stdout (1) vs stderr (2) is essential: in CI you often want errors visible but stdout captured, and 2>&1 (redirect stderr to wherever stdout currently points) is the classic move. Order matters — > all.txt 2>&1 works; 2>&1 > all.txt does not.
The same toolkit from the Linux guide, now inside scripts:
# Extract, count, rank — the workhorse pipeline
grep ' 500 ' access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head
name=$(echo "$path" | sed 's#.*/##') # basename via sed
ip=$(echo "$line" | cut -d' ' -f1) # first field
count=$(grep -c ERROR app.log) # count matches
Reach for grep to filter, awk for columns and math, sed for substitution, cut/sort/uniq for the rest. For anything with real data structures (JSON), pipe to jq rather than parsing with regex.
A script that fails silently in production is worse than one that stops. Start real scripts with:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
set -e — exit immediately if any command fails (no plowing ahead after an error).set -u — error on unset variables (catches typos like $DEST vs $DST).set -o pipefail — a pipeline fails if any stage fails, not just the last.trap for cleanup on exit:tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT # always clean up, even on error
Run shellcheck on every script — it catches quoting bugs, unused vars, and footguns before they reach CI. It's the single highest-leverage habit in Bash.
set -euo pipefail + shellcheck on every non-trivial script. Non-negotiable."$var", "$@") unless you have a specific reason not to.local for function variables; keep the global namespace clean.${VAR:?} and usage checks — fail loud, fail fast.mktemp over hard-coded /tmp/foo, and clean up with trap.rm $file on "my file.txt" deletes the wrong things. Quote everything.set -euo pipefail. Scripts sail past failures and corrupt state. Enable strict mode.for line in $(cat file). Splits on whitespace, not lines. Use while IFS= read -r.ls output. Fragile with spaces/newlines. Glob (for f in *.log) or use find -print0 | xargs -0.sh and bash. #!/bin/sh disables Bash features. Use the env bash shebang and run with bash.$?/using && means failures pass silently.mktemp + trap ... EXIT.set -euo pipefail, run shellcheck on it, and fix every warning.*.log in a directory (including names with spaces) and prints its line count — prove it works on "my file.log".retry() { ... } that runs a command up to 3 times with a 2-second pause, returning success as soon as it works and failing otherwise.grep | awk | sort | uniq -c | sort -rn pipeline.Self-check:
"$var" and "$@"?set -e, set -u, and pipefail each protect against?for line in $(cat file) wrong, and what's the fix?run: step is Bash.You now have the full loop: shebang → quote safely → branch on exit codes → loop → functions → strict mode → ship. That's Bash you can trust in production.
Learned the concepts? Test yourself with Bash interview questions.
Go to the question bank →