what is a shell script?

A shell script is just a text file of the same commands you'd type interactively at the terminal, run all at once by a shell interpreter. Bash is the default interactive shell on most Linux distributions (including Ubuntu) and the one this page assumes throughout — some of what's here (arrays, [[ ]], ${VAR^^}) is a bash extension, not portable to a plain POSIX /bin/sh.
#!/usr/bin/env bash | | the shebang: the very first line of a script, telling the OS which interpreter to run it with. Using env finds bash wherever it's installed on $PATH, rather than assuming it's at /bin/bash |'bs1'
chmod +x script.sh | | makes a script file executable |'bs2'
./script.sh | | runs an executable script (must be in the current directory, or given a path) |'bs3'
bash script.sh | | runs a script by explicitly invoking bash on it, without needing it to be executable or have a shebang at all |'bs4'
bash --version | | shows the installed bash version |'bs5'

variables & quoting

name="Ada"
echo "Hello, $name"        # Hello, Ada
echo "Hello, ${name}!"     # braces needed when concatenating right up against text
No spaces are allowed around the = in an assignment — name = "Ada" is parsed as running a command called name with arguments = and "Ada", not an assignment, and fails.
"$var" | | double quotes: expands the variable, but keeps it as one word — the safe default. Always quote variable expansions unless you specifically want word-splitting |'bs6'
'$var' | | single quotes: literal, no expansion at all — useful for regexes, awk scripts, or anything you don't want bash to touch |'bs7'
$var | | unquoted: bash word-splits the value on whitespace and expands any glob characters in it — almost never what you want; a filename with a space in it will silently become two arguments |'bs8'
readonly name="Ada" | | declares a variable that can't be reassigned later in the script |'bs9'
unset name | | removes a variable entirely (different from setting it to an empty string) |'bs10'
export PATH="$PATH:/opt/bin" | | marks a variable as an environment variable, inherited by any child process this shell launches; without export it stays local to this shell |'bs11'
declare -i n=5 | | restricts a variable to integer values — arithmetic is then applied automatically to it even without $(( )) |'bs104'
declare -u name / declare -l name | | forces a variable to always be stored uppercase / lowercase, on every assignment from then on |'bs105'
declare -p | | prints every variable currently in scope, each as the declare command that would recreate it — a quick way to inspect what's set |'bs106'

brace expansion

Brace expansion is pure text generation, and happens before any other expansion — bash doesn't need the files or variables it mentions to actually exist, which is what makes it safe to use for things like generating filenames in bulk.
touch file{1,2,3}.txt | | expands to each comma-separated item: touch file1.txt file2.txt file3.txt |'bs109'
echo {1..5} | | a numeric range: 1 2 3 4 5 |'bs110'
echo {1..10..2} | | a numeric range with a step: 1 3 5 7 9 |'bs111'
echo {a..e} | | an alphabetic range: a b c d e |'bs112'
touch file{1..3}_{a..c}.txt | | multiple brace expressions combine into every pairing — 9 filenames here, file1_a.txt through file3_c.txt |'bs113'

command-line arguments & special variables

$0 | | the script's own name (or path, depending on how it was invoked) |'bs12'
$1, $2, ... | | positional arguments passed to the script (or function). Use ${10} with braces for the 10th argument and beyond |'bs13'
$# | | the number of positional arguments |'bs14'
"$@" | | all arguments, each as its own separately-quoted word — the correct way to forward "$@" on to another command unchanged, spaces-in-arguments and all |'bs15'
"$*" | | all arguments joined into a single word, separated by the first character of $IFS (a space, by default) — rarely what you actually want; prefer "$@" |'bs16'
$? | | the exit status of the last command (0 = success, nonzero = failure) |'bs17'
$$ | | the current shell's own process ID |'bs18'
$! | | the process ID of the most recently started background job |'bs19'
shift | | discards $1 and shifts every remaining positional argument down by one — a common way to loop through arguments one at a time |'bs20'

input & output

echo "text $var" | | prints text and a trailing newline. Behavior of flags like -e varies across shells; for anything that needs to be predictable, prefer printf |'bs21'
echo -n "text" | | suppresses the trailing newline |'bs99'
echo -e "a\tb\nc" | | interprets backslash escapes like \t, \n, and \a (terminal bell) when -e is given — plain echo prints them literally |'bs100'
printf '%s\n' "$var" | | prints a value with an explicit, portable format string — no surprises across environments the way echo can have |'bs22'
printf "[%5d][%-5d]\n" 3 3 | | field-width and alignment: right-pads with spaces by default, - left-aligns instead |'bs101'
printf "%(%Y-%m-%d %H:%M:%S)T\n" "$(date +%s)" | | formats a Unix timestamp as a human-readable date without needing an external date command; omit the argument to format the current time |'bs102'
read -p "Name: " name | | prompts for and reads a line of input into the variable name |'bs23'
read -r line | | reads a line without letting backslashes escape characters — almost always what you want; use it by default |'bs24'
read -s -p "Password: " pw | | reads input without echoing it to the terminal |'bs25'
read -e -i "default" -p "Value: " var | | pre-fills the input line with a default the user can edit or accept as-is (-e turns on readline-style editing) |'bs103'
while IFS= read -r line; do ... done < file.txt | | the standard idiom for reading a file line by line; IFS= and -r together preserve leading/trailing whitespace and backslashes exactly |'bs26'

conditionals

if [[ -f "$file" ]]; then
  echo "exists"
elif [[ -d "$file" ]]; then
  echo "it's a directory"
else
  echo "not found"
fi
[[ ]] is a bash keyword (not portable to plain sh) and is the one to reach for in bash scripts: unlike [ ]/test, unquoted variables inside it don't undergo word-splitting or globbing, and it supports &&/|| directly inside the brackets and == pattern matching.
[[ -z "$var" ]] | | true if the string is empty |'bs27'
[[ -n "$var" ]] | | true if the string is non-empty |'bs28'
[[ "$a" == "$b" ]] | | string equality (right side is treated as a glob pattern if unquoted, e.g. [[ $f == *.txt ]]) |'bs29'
[[ "$a" =~ ^[0-9]+$ ]] | | true if the string matches an extended regular expression |'bs30'
[[ $a -eq $b ]] | | numeric equality (also -ne, -lt, -le, -gt, -ge) — note this compares numbers, not strings; use == for strings |'bs31'
[[ -e "$path" ]] | | true if the path exists at all (any type) |'bs32'
[[ -f "$path" ]] | | true if it's a regular file |'bs33'
[[ -d "$path" ]] | | true if it's a directory |'bs34'
[[ -r "$path" ]] / [[ -w ]] / [[ -x ]] | | true if the path is readable/writable/executable |'bs35'
[[ -s "$path" ]] | | true if the file exists and has a size greater than zero |'bs36'
[[ cond1 && cond2 ]] / [[ cond1 || cond2 ]] logical and/or, usable directly inside a single [[ ]]

loops

for x in 1 2 3; do echo "$x"; done | | iterates over a fixed list of words |'bs38'
for f in *.txt; do echo "$f"; done | | iterates over files matching a glob — safe with spaces in filenames, unlike parsing the output of ls |'bs39'
for f in [^.]*; do echo "$f"; done | | a character-class glob: [^tsq]* matches any name that does NOT start with t, s, or q — ^ or ! right after the opening [ negates the class |'bs114'
for ((i=0; i<10; i++)); do echo "$i"; done | | a C-style numeric for loop |'bs40'
while [[ condition ]]; do ... done | | repeats as long as the condition is true |'bs41'
until [[ condition ]]; do ... done | | repeats until the condition becomes true (the inverse of while) |'bs42'
break / continue | | exits the innermost loop / skips to the next iteration. Both accept an optional number (break 2) to act on an outer loop |'bs43'
select choice in "cat" "dog" "bird"; do
  echo "you picked $choice"
  break
done
select builds a numbered menu from a list, prompts for a number, and puts the corresponding item in the loop variable — a quick way to get an interactive menu without hand-rolling one out of read and a case. Like other loops, it repeats until break (or the user sends EOF).

case statements

case "$1" in
  start|up)
    echo "starting"
    ;;
  stop|down)
    echo "stopping"
    ;;
  *)
    echo "usage: $0 {start|stop}"
    exit 1
    ;;
esac
Each pattern branch ends with ;;; patterns support the same glob syntax as filename matching, and | separates alternatives for a single branch. *) is the conventional catch-all default, matching anything not matched above it.

functions

greet() {
  local name="$1"
  echo "Hello, $name"
}

greet "Ada"
result=$(greet "Ada")   # capture the printed output instead of letting it print
local var="value" | | declares a variable scoped to the current function, instead of leaking into the global/calling scope — use this for every variable inside a function unless you specifically want it global |'bs44'
return 0 | | exits a function with a numeric exit status (0-255), checkable via $? — a function cannot return a string this way |'bs45'
result=$(my_func) | | the way to get a string "result" out of a function: have the function echo/printf it, and capture that output via command substitution |'bs46'
$1, $2, "$@" | | inside a function, these refer to the function's own arguments, not the script's — they're shadowed for the duration of the call |'bs47'

arrays

fruits=("apple" "banana" "cherry")
fruits+=("date")            # append

echo "${fruits[0]}"         # apple
echo "${fruits[@]}"         # every element, each its own word
echo "${#fruits[@]}"        # 4 -- the number of elements

for f in "${fruits[@]}"; do
  echo "$f"
done
declare -A map | | declares an associative array (string keys, bash 4.0+) |'bs48'
map[key]="value" | | sets a key in an associative array |'bs49'
"${map[key]}" | | reads a value by key |'bs50'
"${!map[@]}" | | every key in an associative array (or every index, for a regular indexed array) |'bs51'
unset 'arr[2]' | | removes one element from an array by index/key |'bs52'

string manipulation & parameter expansion

${#var} | | the length of a string |'bs53'
${var:offset:length} | | a substring, starting at offset for length characters |'bs54'
${var#pattern} | | removes the shortest match of pattern from the front |'bs55'
${var##pattern} | | removes the longest match of pattern from the front (e.g. ${path##*/} to get a basename) |'bs56'
${var%pattern} | | removes the shortest match of pattern from the end |'bs57'
${var%%pattern} | | removes the longest match of pattern from the end (e.g. ${file%%.*} to strip everything from the first dot on) |'bs58'
${var/old/new} | | replaces the first match of old with new |'bs59'
${var//old/new} | | replaces every match of old with new |'bs60'
${var^^} / ${var,,} | | uppercases / lowercases the entire string (bash 4.0+) |'bs61'
${var:-default} | | evaluates to default if var is unset or empty, WITHOUT changing var itself |'bs62'
${var:=default} | | same, but also assigns default to var when it was unset or empty |'bs63'
${var:+alt} | | evaluates to alt only if var IS set and non-empty (the opposite condition of :-) |'bs64'
${var:?error message} | | if var is unset or empty, prints error message and exits the script — a quick way to assert a required variable is present |'bs65'

arithmetic, command substitution & subshells

$(( 2 + 3 * 4 )) | | arithmetic expansion — evaluates an integer expression and substitutes the result. Bash only does integer arithmetic, no floats |'bs66'
(( i++ )) | | arithmetic command: evaluates the expression for its side effect. Its exit status reflects whether the RESULT is nonzero (0 result = failure/exit 1), which trips up set -e if the final value happens to be 0 |'bs67'
echo "scale=3; 10/3" | bc bash's $(( )) can't do floating-point math; piping an expression to bc (a separate calculator program) is the standard workaround, with scale setting the decimal precision
$RANDOM | | expands to a new pseudo-random integer between 0 and 32767 each time it's referenced; $((RANDOM % 10)) gives a random number from 0-9 |'bs108'
$(command) | | command substitution: runs command and substitutes its stdout — the modern, nestable form. Prefer this over the legacy `command` backtick syntax |'bs68'
(cmd1; cmd2) | | runs commands in a subshell: variable assignments and cd calls inside don't affect the calling shell |'bs69'
{ cmd1; cmd2; } | | runs commands in the CURRENT shell instead (note the required spaces and the trailing semicolon before the closing brace) — use this when you need the effects (like a cd) to persist |'bs70'

redirection & pipes

cmd > file | | redirects stdout to a file, overwriting it |'bs71'
cmd >> file | | redirects stdout to a file, appending instead of overwriting |'bs72'
cmd < file | | uses a file as stdin instead of the keyboard |'bs73'
cmd 2> errors.log | | redirects stderr only, leaving stdout untouched |'bs74'
cmd > out.log 2>&1 | | redirects stdout to a file, then points stderr at wherever stdout now goes — the correct order to merge both into one file. Reversing the order (2>&1 before >out.log) sends stderr to the terminal instead, since stdout hadn't been redirected yet at that point |'bs75'
cmd &> out.log | | bash shorthand that redirects both stdout and stderr to a file in one go |'bs76'
cmd > /dev/null 2>&1 | | discards all output, both streams |'bs77'
cmd1 | cmd2 pipes cmd1's stdout into cmd2's stdin
cat <<EOF
Multi-line text with $variable expansion.
EOF

cat <<'EOF'
Literal text -- $this is NOT expanded here.
EOF

grep "$pattern" <<< "$single_line_string"
A here-document (<<DELIM ... DELIM) feeds multi-line text to a command's stdin; quoting the delimiter (<<'EOF') disables variable expansion inside it. A here-string (<<<) does the same for a single expression, without needing a whole block.

exit codes & error handling

exit 1 | | ends the script immediately with the given exit status (0-255; 0 means success by convention) |'bs79'
cmd1 && cmd2 | | runs cmd2 only if cmd1 succeeded (exit status 0) |'bs80'
cmd1 || cmd2 runs cmd2 only if cmd1 failed
set -e (errexit) exits the script immediately if any command fails. Doesn't trigger inside an if/while condition, inside all-but-the-last part of an &&/|| chain, or inside a function called from one of those — treat it as a safety net, not a guarantee
set -u | | (nounset) errors out immediately when referencing a variable that was never set, instead of silently treating it as empty — catches a whole class of typos |'bs83'
set -o pipefail makes a pipeline's exit status the last NONZERO status among all its commands, instead of just the final command's — without this, cmd_that_fails | cmd_that_succeeds reports success
set -euo pipefail | | the common combination of the three above, often called bash's unofficial "strict mode" — a reasonable default at the top of a script, with the set -e caveats above still in mind |'bs85'
trap 'rm -f "$tmpfile"' EXIT | | runs a command when the script exits, for any reason (normal exit, an uncaught error under set -e, or a signal) — the standard way to guarantee cleanup |'bs86'
trap 'echo interrupted; exit 1' INT | | runs a command when the script receives a specific signal (INT is Ctrl-C) |'bs87'

advanced

type echo | | shows whether a name is a shell builtin, a function, an alias, or an external executable, and where |'bs115'
command echo hi | | runs the external/PATH version of a command, bypassing any shell builtin or function of the same name |'bs116'
enable -n echo | | disables a shell builtin so a plain call falls through to the external command instead; enable echo re-enables it |'bs117'
diff &lt;(cmd1) &lt;(cmd2) | | process substitution: lets a command's output be read as if it were a file, without a real temp file — handy for diff, comm, join |'bs88'
cmd & | | runs a command in the background, immediately continuing the script |'bs89'
jobs | | lists the current shell's background jobs |'bs90'
wait $pid | | blocks until the given background process finishes, and makes its exit status available as $? |'bs91'
mktemp | | creates a uniquely-named temporary file safely (no race condition from guessing a name yourself) and prints its path |'bs92'
mktemp -d | | same, for a temporary directory |'bs93'
find . -print0 | xargs -0 cmd builds and runs a command line from a list of filenames read from stdin. The -print0/-0 pairing uses NUL as a separator instead of newlines, the only safe way to handle filenames containing spaces or even newlines
while getopts "vf:" opt; do
  case "$opt" in
    v) verbose=1 ;;
    f) file="$OPTARG" ;;
    *) echo "usage: $0 [-v] [-f file]"; exit 1 ;;
  esac
done
getopts is the standard way to parse -v-style flags and -f value-style options in a script, instead of hand-rolling argument parsing with a series of if/case checks against "$@".

debugging

bash -n script.sh | | checks the script's syntax without actually running any of it |'bs95'
bash -x script.sh | | runs the script with execution tracing: prints every command (with variables already expanded), prefixed with +, right before it runs |'bs96'
set -x / set +x | | turns execution tracing on/off partway through a script, for just the section you're debugging |'bs97'
shellcheck script.sh | | a static analyzer (separate install: sudo apt install shellcheck) that catches a large fraction of real bash bugs — missing quotes, unreachable code, wrong test operators — before you run the script at all |'bs98'

related topics

Ubuntu Terminal Commands — the individual commands most bash scripts string together.
tmux Cheat Sheet — keeping a long-running script alive independent of your terminal connection.
Git Hooks & Automation — bash scripts that git itself invokes automatically.
Docker Cheat Sheet — the RUN instructions in a Dockerfile are shell commands too.

reference

GNU Bash Reference Manual
ShellCheck
Greg's Wiki: Bash Pitfalls