Linux: Shell Scripts
Linux Shell Scripts
Table of Contents
- Script Basics
- Quoting and Literals
- Variables and Special Variables
- Exit Codes and Conditionals
- Loops
- Command Substitution
- Temporary File Management
- Here Documents
- Important Shell Script Utilities
- Subshells
- Writing Safer Scripts
- Cheat Sheet
1. Script Basics
A shell script is a text file of shell commands. The first line, the shebang, tells the kernel which interpreter runs it:
#!/bin/sh
echo "Hello from $0"
Common shebangs:
| Shebang | Use |
|---|---|
#!/bin/sh | Portable POSIX shell scripts |
#!/bin/bash | Scripts using bash features (arrays, [[ ]], …) |
#!/usr/bin/env bash | Find bash via $PATH (useful when it’s not in /bin, e.g. macOS/Nix) |
Make the script executable and run it:
chmod +rx script # read + execute for everyone (the shell must READ the file to run it)
./script # run it from the current directory
sh script # or pass it to an interpreter explicitly (shebang ignored)
What happens when you run ./script
$ ./script arg1
│
▼
┌─────────────────────────┐
│ kernel: execve() │ reads first line: "#!/bin/sh"
└───────────┬─────────────┘
▼
┌─────────────────────────┐
│ runs: /bin/sh ./script │ a NEW shell process
│ arg1 │ ($0 = ./script, $1 = arg1)
└───────────┬─────────────┘
▼
┌─────────────────────────┐
│ sh reads the file line │ variables and `cd` die with this
│ by line and runs it │ process; they don't affect your shell
└─────────────────────────┘
./is needed because the current directory usually isn’t in$PATH.- A script runs in a child process. A
cdor variable set in the script doesn’t change your interactive shell. To run commands in your current shell, use. ./script(orsource ./scriptin bash). - Scripts are best for automating commands you’d otherwise type. For heavy string processing or data structures, Python is usually the better tool.
2. Quoting and Literals
Literals
A literal is a string the shell passes to a command unchanged. Before running a command, the shell expands the line:
You type: echo $HOME *.txt "a b"
│ │ │
Shell expands: ▼ ▼ ▼
variables globs quotes removed,
spacing kept
Command gets: echo /home/juser notes.txt todo.txt "a b"
(one argument)
Quotes control which expansions happen.
Single quotes
Everything inside single quotes is literal. No expansion at all:
echo '$HOME *.txt $(date)' # prints: $HOME *.txt $(date)
grep 'r.*t' /etc/passwd # regex reaches grep unchanged
Double quotes
Inside double quotes, variables, command substitution and arithmetic expand, but globbing and word splitting don’t:
echo "$HOME *.txt $(date +%Y)" # prints: /home/juser *.txt 2026
Inside "…" | Expands? |
|---|---|
$var, ${var} | Yes |
$(cmd), `cmd` | Yes |
$((1 + 2)) | Yes |
*, ?, […] globs | No |
| Word splitting on spaces | No |
\ before $ ` " \ | Escapes them |
Always double-quote variables. Without quotes, a value with spaces is split into several arguments and globs inside it expand:
file="my notes.txt"
rm $file # ✗ runs: rm my notes.txt → two files: "my" and "notes.txt"
rm "$file" # ✓ runs: rm "my notes.txt"
Literal single quotes
A single quote can’t appear inside single quotes, not even with a backslash. Options:
echo "It's fine" # 1. use double quotes
echo 'It'\''s fine' # 2. end quote, escaped ', reopen quote
echo It\'s fine # 3. backslash outside quotes
Option 2, step by step:
'It' \' 's fine'
└┬─┘ └┬┘ └──┬───┘
quoted literal quoted → glued together: It's fine
bash also has $'…' strings with C-style escapes: echo $'It\'s\tTabbed\n'.
3. Variables and Special Variables
name="Andrey" # no spaces around =
echo "Hi $name"
echo "${name}_backup" # braces separate the name from following text
readonly PI=3.14
unset name
Default values:
| Expression | Result |
|---|---|
${var:-default} | $var, or default if unset/empty |
${var:=default} | Same, and also assigns default to var |
${var:?message} | Exit with message if unset/empty |
${#var} | Length of the value |
dir="${1:-.}" # first argument, or current directory
: "${API_TOKEN:?API_TOKEN is not set}" # fail early if missing
Special variables
| Variable | Meaning |
|---|---|
$0 | Name of the script as it was called |
$1, $2, … ${10} | Positional arguments |
$# | Number of arguments |
$@ | All arguments; "$@" keeps each one as a separate word |
$* | All arguments; "$*" joins them into one word |
$? | Exit code of the last command |
$$ | PID of the current shell |
$! | PID of the last background job |
$- | Current shell options |
"$@" vs "$*" with arguments a "b c":
"$@" → [a] [b c] ← two arguments, spaces preserved (almost always what you want)
"$*" → [a b c] ← one argument
$@ → [a] [b] [c] ← unquoted: split again, broken
shift
shift drops $1 and moves everything down by one:
before: $1=-v $2=file1 $3=file2 $#=3
shift
after: $1=file1 $2=file2 $#=2
#!/bin/sh
while [ $# -gt 0 ]; do
echo "arg: $1"
shift
done
4. Exit Codes and Conditionals
Every command returns an exit code: 0 means success, anything else (1–255) means failure. Shell if tests exit codes, not booleans.
ls /nonexistent
echo $? # → 2
Scripts should return a meaningful code with exit:
[ -f "$1" ] || { echo "usage: $0 file" >&2; exit 1; }
Note: grep returns 1 for “no match”, which isn’t an error. diff returns 1 for “files differ”. Check man pages for each command’s codes.
if and test ([)
[ is a command (also called test). It needs spaces around its brackets and arguments.
if [ "$count" -gt 10 ]; then
echo "many"
elif [ "$count" -eq 0 ]; then
echo "none"
else
echo "some"
fi
| String tests | Number tests | File tests | |||
|---|---|---|---|---|---|
"$a" = "$b" | equal | -eq | equal | -e | exists |
"$a" != "$b" | not equal | -ne | not equal | -f | regular file |
-z "$a" | empty | -lt / -le | less (or equal) | -d | directory |
-n "$a" | not empty | -gt / -ge | greater (or equal) | -r / -w / -x | readable / writable / executable |
-s | not empty | ||||
f1 -nt f2 | f1 newer than f2 |
Combine tests: ! (not), and commands with && / ||:
if [ -f "$f" ] && [ -r "$f" ]; then …
if ! grep -q "^juser:" /etc/passwd; then echo "no such user"; fi
Any command works as a condition:
if ping -c1 -W1 192.168.1.20 >/dev/null 2>&1; then
echo "T490 is up"
fi
bash: [[ … ]] is safer and more powerful: no word splitting, &&/|| inside, glob match [[ $f == *.md ]], regex [[ $s =~ ^[0-9]+$ ]].
&& and ||
mkdir -p build && cd build # cd only if mkdir succeeded
cd /some/dir || exit 1 # stop if cd fails
case
Matches a value against glob patterns:
case "$1" in
start) echo "starting" ;;
stop|halt) echo "stopping" ;;
*.tar.gz) tar xzf "$1" ;;
-h|--help) echo "usage: $0 start|stop" ;;
*) echo "unknown: $1" >&2; exit 1 ;;
esac
5. Loops
for
for loops over a list of words:
for f in *.log; do
echo "compressing $f"
gzip "$f"
done
for host in t490 p16v nas; do
ssh "$host" uptime
done
for arg in "$@"; do echo "$arg"; done # loop over script arguments
If no file matches *.log, the pattern stays as the literal word *.log. Guard with [ -e "$f" ] || continue.
Number ranges: for i in $(seq 1 5) (POSIX-ish) or bash for i in {1..5} and for ((i=0; i<5; i++)).
while and until
while runs as long as a command succeeds; until runs as long as it fails:
n=1
while [ "$n" -le 5 ]; do
echo "$n"
n=$((n + 1))
done
until curl -sf http://localhost:8080/health >/dev/null; do
echo "waiting for service…"; sleep 2
done
Reading a file line by line
while IFS= read -r line; do
echo "line: $line"
done < input.txt
IFS=keeps leading/trailing spaces.-rkeeps backslashes literal.
Loop control: break leaves the loop, continue skips to the next iteration.
6. Command Substitution
$(command) runs a command and replaces itself with the command’s output (trailing newlines removed):
today=$(date +%F) # 2026-10-17
kernel=$(uname -r)
count=$(wc -l < /etc/passwd)
echo "Backup for $today has $(ls backups | wc -l) files"
echo "kernel: $(uname -r)"
└────┬───┘
│ 1. run uname -r in a subshell
│ 2. capture stdout: "6.17.1-300.fc43.x86_64\n"
│ 3. strip trailing newline
▼
echo "kernel: 6.17.1-300.fc43.x86_64"
- Prefer
$(…)over the old backticks`…`: it nests cleanly ($(dirname $(readlink -f "$0"))) and is easier to read. - Quote it when the output may contain spaces:
dir="$(pwd)". - Arithmetic is a different thing:
$((2 * 21))→42.
7. Temporary File Management
Never hard-code temp names like /tmp/myscript.tmp: two runs collide, and another user could pre-create the file (symlink attacks). Use mktemp:
#!/bin/sh
tmpfile=$(mktemp) # e.g. /tmp/tmp.k3Xa9QzL1v
tmpdir=$(mktemp -d) # temporary directory
trap 'rm -rf "$tmpfile" "$tmpdir"' EXIT # clean up however the script ends
ps aux > "$tmpfile"
grep sshd "$tmpfile"
trap
trap runs a command when the shell receives a signal or exits:
script starts ─► trap cleanup EXIT INT TERM ─► work … ─┬─► normal end ──────┐
├─► exit 1 ──────────┤
└─► Ctrl-C (INT) ────┤
▼
cleanup runs → temp files gone
cleanup() { rm -f "$tmpfile"; }
trap cleanup EXIT
trap 'exit 1' INT TERM # turn Ctrl-C/kill into a normal exit, so EXIT trap fires
EXIT is the main one to use. Signals: INT (Ctrl-C), TERM (kill), HUP (terminal closed). KILL can’t be trapped.
8. Here Documents
A here document feeds multiple lines to a command’s stdin:
cat <<EOF
Host: $(hostname)
User: $USER
Date: $(date)
EOF
cat > /etc/myapp.conf <<'EOF'
# quoted delimiter: no expansion, $HOME stays literal
path=$HOME/data
EOF
<<EOF: variables and$(…)expand.<<'EOF': everything literal.<<-EOF: leading tabs (not spaces) are stripped, so you can indent the block.
Handy for creating Kubernetes manifests in a script: kubectl apply -f - <<EOF … EOF.
9. Important Shell Script Utilities
awk
awk processes text line by line, splitting each line into fields ($1, $2, …; $0 = whole line).
input line: juser pts/0 2026-10-17 16:40 (192.168.1.10)
fields: $1 $2 $3 $4 $5 NF=5
ls -l | awk '{print $5, $9}' # size and name columns
awk -F: '{print $1, $7}' /etc/passwd # -F sets separator: user + shell
awk -F: '$3 >= 1000 {print $1}' /etc/passwd # pattern {action}: regular users
df -h | awk 'NR > 1 {print $5, $6}' # skip header line (NR = line number)
awk '{sum += $1} END {print sum}' numbers.txt # total of column 1
ps aux | awk '$3 > 10 {print $2, $11}' # processes using >10% CPU
Structure: awk 'pattern { action }'. BEGIN { } runs before input, END { } after.
sed
sed (stream editor) edits text as it flows through, mostly with substitutions:
sed 's/old/new/' file # replace first match on each line
sed 's/old/new/g' file # replace all matches
sed -n '5,10p' file # print only lines 5–10
sed '/^#/d' file # delete comment lines
sed '/^$/d' file # delete empty lines
sed 's|/usr/local|/opt|g' file # any delimiter works (handy for paths)
sed -i 's/^PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config # ⚠️ edit in place
sed -i.bak 's/foo/bar/' file # in place, keep file.bak backup
s / pattern / replacement / flags
│ │ │ └── g = all matches, i = ignore case
│ │ └── & = the matched text, \1 = first group
│ └── regular expression
└── substitute command
xargs
xargs turns input lines into arguments for a command. Use it when a command doesn’t read stdin but you have a list of items:
find . -name '*.tmp' xargs rm
┌──────────────┐ ┌──────────────────────────────┐
│ ./a.tmp │ │ rm ./a.tmp ./b.tmp ./c.tmp │
│ ./b.tmp │ ────────► │ (as few calls as possible) │
│ ./c.tmp │ stdin └──────────────────────────────┘
└──────────────┘
find . -name '*.tmp' -print0 | xargs -0 rm # -0: safe with spaces in names
cat hosts.txt | xargs -n1 ping -c1 # one argument per command
ls *.png | xargs -I{} convert {} {}.jpg # {} = placeholder
find . -name '*.log' -print0 | xargs -0 -P4 gzip # 4 jobs in parallel
Plain xargs splits on spaces, so filenames with spaces break. Always pair find -print0 with xargs -0, or use find -exec cmd {} +.
expr
expr is the old way to do arithmetic and string operations in scripts:
expr 1 + 2 # 3 (spaces between every token!)
expr 5 \* 3 # 15 (* must be escaped)
expr length "hello" # 5 (GNU)
It’s slow (an external program) and awkward. Use the shell’s built-in arithmetic instead:
echo $((1 + 2)) # 3
i=$((i + 1))
echo $((17 % 5)) # 2
exec
exec has two uses:
1. Replace the shell with a command. No new process: the command takes over the shell’s PID, and the script ends there.
Without exec: With exec:
sh script (PID 100) sh script (PID 100)
└── myserver (PID 101) │ exec myserver
▼
myserver (PID 100) ← same PID, sh is gone
#!/bin/sh
# typical container entrypoint / wrapper script
export CONFIG=/etc/app.conf
exec /usr/bin/myserver "$@"
This matters in containers: with exec, the server becomes PID 1 and receives signals like SIGTERM directly, so podman stop or Kubernetes pod shutdown works cleanly.
2. Redirect the script’s own file descriptors.
exec > /var/log/myscript.log 2>&1 # all following output goes to the log
exec 3< input.txt # open fd 3 for reading
read -r first_line <&3
10. Subshells
A subshell is a copy of the current shell running as a child process. Changes inside it (variables, cd, environment) are thrown away when it ends.
(cd /tmp && ls) # cd only inside the subshell
pwd # still the original directory
parent shell (PWD=/home/juser, x=1)
│
├── ( cd /tmp; x=2; ls ) ── subshell: own copy of PWD and x
│ ends → changes discarded
▼
parent shell (PWD=/home/juser, x=1) ← unchanged
Uses:
# change environment just for one group of commands
(PATH=/opt/tools/bin:$PATH; export PATH; build.sh)
# copy a directory tree through a pipe (classic)
tar cf - orig | (cd target && tar xvf -)
# group output of several commands
(date; uptime; df -h) > report.txt
{ cmd1; cmd2; } groups commands without a subshell (note the spaces and the final ;). Use it when you want the changes to stay.
Where subshells hide
Subshells are also created by:
$(…)command substitution- background jobs (
cmd &) - each part of a pipeline (in bash, by default)
The pipeline case is a classic bug:
count=0
cat file | while read -r line; do count=$((count + 1)); done
echo "$count" # → 0 ! the loop ran in a subshell
count=0
while read -r line; do count=$((count + 1)); done < file
echo "$count" # ✓ correct: no pipe, no subshell
11. Writing Safer Scripts
#!/bin/sh
set -eu
| Option | Effect |
|---|---|
set -e | Exit when a command fails (with exceptions: inside if, &&/` |
set -u | Treat unset variables as errors (catches typos like $fiel) |
set -x | Print each command before running it (debugging); run as sh -x script |
set -o pipefail | bash (and newer POSIX shells): a pipeline fails if any part fails, not just the last |
Common bash header: set -euo pipefail.
More habits:
- Quote every variable:
"$var","$@". - Use
mktemp+trap … EXITfor temp files. - Send errors to stderr:
echo "error" >&2. - Check scripts with ShellCheck:
shellcheck script.shcatches quoting bugs, unsafe patterns and bashisms in#!/bin/shscripts.
Template
#!/bin/sh
set -eu
usage() { echo "usage: $0 <source-dir> <dest-host>" >&2; exit 1; }
[ $# -eq 2 ] || usage
src=$1
dest=$2
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
[ -d "$src" ] || { echo "not a directory: $src" >&2; exit 1; }
find "$src" -type f -newer "$src/.last-backup" > "$tmp" 2>/dev/null || find "$src" -type f > "$tmp"
echo "$(wc -l < "$tmp") files to copy"
rsync -a --files-from="$tmp" / "$dest:/backup/"
touch "$src/.last-backup"
12. Cheat Sheet
| Task | Syntax |
|---|---|
| Shebang | #!/bin/sh, #!/usr/bin/env bash |
| Make executable | chmod +rx script |
| Run in current shell | . ./script |
| No expansion | 'literal $text' |
| Expand vars, keep spaces | "$var" |
Literal ' inside quotes | 'It'\''s' |
| Default value | ${1:-default} |
| Argument count / all args | $# / "$@" |
| Last exit code | $? |
| Script PID / last bg PID | $$ / $! |
| Test | [ -f "$f" ], [ "$a" = "$b" ], [ "$n" -gt 5 ] |
| Branch on pattern | case "$x" in a) … ;; *) … ;; esac |
| Loop over files | for f in *.txt; do …; done |
| Read lines | while IFS= read -r line; do …; done < file |
| Command output | $(cmd) |
| Arithmetic | $((a + b)) |
| Temp file + cleanup | t=$(mktemp); trap 'rm -f "$t"' EXIT |
| Print column | awk '{print $2}', awk -F: '{print $1}' |
| Replace text | sed 's/old/new/g', sed -i … in place |
| Lines → arguments | find … -print0 | xargs -0 cmd |
| Replace shell with command | exec cmd "$@" |
| Isolated changes | ( cd dir && cmd ) |
| Strict mode | set -eu (bash: set -euo pipefail) |
| Debug | sh -x script |
| Lint | shellcheck script |