Andrey's Blog

Linux: Shell Scripts

Linux Shell Scripts

Table of Contents

  1. Script Basics
  2. Quoting and Literals
  3. Variables and Special Variables
  4. Exit Codes and Conditionals
  5. Loops
  6. Command Substitution
  7. Temporary File Management
  8. Here Documents
  9. Important Shell Script Utilities
  10. Subshells
  11. Writing Safer Scripts
  12. 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:

ShebangUse
#!/bin/shPortable POSIX shell scripts
#!/bin/bashScripts using bash features (arrays, [[ ]], …)
#!/usr/bin/env bashFind 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
 └─────────────────────────┘

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
*, ?, […] globsNo
Word splitting on spacesNo
\ 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:

ExpressionResult
${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

VariableMeaning
$0Name 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 testsNumber testsFile tests
"$a" = "$b"equal-eqequal-eexists
"$a" != "$b"not equal-nenot equal-fregular file
-z "$a"empty-lt / -leless (or equal)-ddirectory
-n "$a"not empty-gt / -gegreater (or equal)-r / -w / -xreadable / writable / executable
-snot empty
f1 -nt f2f1 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

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"

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

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:

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
OptionEffect
set -eExit when a command fails (with exceptions: inside if, &&/`
set -uTreat unset variables as errors (catches typos like $fiel)
set -xPrint each command before running it (debugging); run as sh -x script
set -o pipefailbash (and newer POSIX shells): a pipeline fails if any part fails, not just the last

Common bash header: set -euo pipefail.

More habits:

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

TaskSyntax
Shebang#!/bin/sh, #!/usr/bin/env bash
Make executablechmod +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 patterncase "$x" in a) … ;; *) … ;; esac
Loop over filesfor f in *.txt; do …; done
Read lineswhile IFS= read -r line; do …; done < file
Command output$(cmd)
Arithmetic$((a + b))
Temp file + cleanupt=$(mktemp); trap 'rm -f "$t"' EXIT
Print columnawk '{print $2}', awk -F: '{print $1}'
Replace textsed 's/old/new/g', sed -i … in place
Lines → argumentsfind … -print0 | xargs -0 cmd
Replace shell with commandexec cmd "$@"
Isolated changes( cd dir && cmd )
Strict modeset -eu (bash: set -euo pipefail)
Debugsh -x script
Lintshellcheck script