Article

Shell Scripting 101

by Gary Worthington, More Than Monkeys

Bash is everywhere. Whether you’re running a Linux server in production, automating local tasks on a Mac, or wiring up a CI/CD pipeline, sooner or later you’ll need to write a shell script. The problem is most developers learn just enough to get by. A bit of cd, some grep, the occasional awk incantation, and then paste snippets from Stack Overflow until it works.

That’s fine until the day your deployment pipeline stops mid-flight because someone put the wrong exit code in. This article gives you the foundations of Bash scripting so you can actually understand what you’re typing, instead of treating it like dark magic.

Starting a Script

Every Bash script starts with a shebang (insert terrible Ricky Martin joke here). It tells the operating system which interpreter should run the file:

#!/bin/bash

or, more portably:

#!/usr/bin/env bash

The latter uses whatever Bash is first on your PATH. Good for portability across different systems.

Make the file executable with:

chmod +x myscript.sh

and run it with:

./myscript.sh

Variables

You don’t need var or let. Just assign:

name="Gary"
echo "Hello, $name"

No spaces around the =. Forget that and you’ll be debugging for half an hour.

To make variables available to child processes, export them:

export DATABASE_URL="postgres://..."

Conditionals

Bash has if, elif, and else:

if [ "$name" = "Gary" ]; then
echo "Welcome back"
elif [ "$name" = "Alice" ]; then
echo "Hello Alice"
else
echo "Who are you?"
fi

Notice the spaces inside [ ]. Without them, it won’t work. Bash is petty like that.

Loops

A for loop:

for file in *.log; do
echo "Processing $file"
done

A while loop:

count=0
while [ $count -lt 5 ]; do
echo "Count is $count"
count=$((count + 1))
done

Functions

Yes, Bash has functions. Define them like this:

greet() {
echo "Hello, $1"
}

greet "World"

Arguments are accessed as $1, $2, etc. $@ gives you all arguments.

Exit Codes

Every command returns an exit code. 0 means success. Anything else means failure.

ls /no/such/dir
echo $? # prints non-zero

Scripts often use set -e so that they exit immediately on the first non-zero status. Useful in CI pipelines.

Redirects and Pipes

This is where things get interesting.

  • > redirects stdout (normal output).
  • 2> redirects stderr (error output).
  • >> appends instead of overwriting.
  • | pipes output from one command to another.

Example:

ls /etc > files.txt 2>/dev/null

Here’s whats happening:

  • ls /etc lists files.
  • > files.txt saves stdout to files.txt.
  • 2>/dev/null discards stderr (so you don’t see permission denied messages).

This trick is used everywhere. Want only the successful output? Send errors to /dev/null.

Another common one:

command >output.log 2>&1

That merges stderr into stdout so you capture both in the same log file.

Quoting

Quoting in Bash is one of those things everyone gets wrong at some point. The rules are simple once you know them, but the consequences of not knowing can be painful.

There are three main kinds of quoting:

1. Double Quotes (" ")

Double quotes expand variables and command substitutions, but protect spaces and most special characters.

name="Gary"
echo "Hello $name"
# Output: Hello Gary

If you didn’t quote it:

echo Hello $name 
# Output: Hello Gary

it still works, but if $name had a space in it ("Gary Worthington"), it would split into two arguments:

name="Gary Worthington"
echo Hello $name
# Output: Hello Gary Worthington
# ...looks fine, but this is actually three arguments

With double quotes, it’s safe:

name="Gary Worthington"
echo "Hello $name"
# Output: Hello Gary Worthington

2. Single Quotes (' ')

Single quotes are literal. Nothing is expanded inside.

name="Gary"
echo 'Hello $name'
# Output: Hello $name

This is useful when you want to print something exactly as written, without Bash trying to be clever.

3. Command Substitution ($(...))

This isn’t technically a quote, but it often goes hand-in-hand. It runs a command and replaces it with the output.

today=$(date)
echo "Today is $today"

Compare with backticks:

today=`date`

Both work, but $(...) is easier to nest:

echo "Number of files: $(ls | wc -l)"

4. Mixing Quotes

Sometimes you need both. For example, you want to expand a variable but also include literal $ signs:

price=50
echo "The cost is $${price}"
# Output: The cost is $50

Here, "$" was escaped with another $, but you could also do:

echo "The cost is $"$price

This works because Bash joins adjacent quoted strings automatically.

5. Escaping

You can escape characters with \ inside double quotes:

echo "This is a \"quoted\" word"
# Output: This is a "quoted" word

In single quotes, escaping doesn’t work. If you want a literal ' inside single quotes, you have to close and reopen the quotes:

echo 'It'\''s tricky'
# Output: It's tricky

Yes, it looks ugly. That’s why most people avoid single quotes unless they need literal text.

Quick Rule of Thumb

  • Use double quotes almost everywhere.
  • Use single quotes when you want literal text.
  • Use $(...) instead of backticks for command substitution.
  • Escape only when you have to.

Scripts That Fail Loudly

One good habit: start scripts with:

#!/usr/bin/env bash
set -euo pipefail
  • -e exits on error.
  • -u treats unset variables as errors.
  • -o pipefail ensures that a failure in a pipeline propagates.

This prevents silent failures. Without it, you’ll happily keep running even when half your commands break.

Putting It Together

Here’s a simple but realistic script that backs up a directory:

#!/usr/bin/env bash
set -euo pipefail

backup_dir=$1
dest=$2

if [ ! -d "$backup_dir" ]; then
echo "Source directory does not exist" >&2
exit 1
fi

tar -czf "$dest/backup.tar.gz" "$backup_dir" 2>/dev/null
echo "Backup created at $dest/backup.tar.gz"

Usage:

./backup.sh /var/www /tmp

Conclusion

Bash is quirky, but it’s also one of the most powerful tools you’ll use as an engineer. Once you know how variables, conditionals, exit codes, redirects, and quoting actually work, you stop treating it like a black box.

And the next time someone pastes 2>/dev/null into a script, you’ll know exactly why it’s there.

Gary Worthington is a software engineer, delivery consultant, and fractional CTO who helps teams move fast, learn faster, and scale when it matters. He writes about modern engineering, product thinking, and helping teams ship things that matter.

Through his consultancy, More Than Monkeys, Gary helps startups and scaleups improve how they build software — from tech strategy and agile delivery to product validation and team development.

Visit morethanmonkeys.co.uk to learn how we can help you build better, faster.

Follow Gary on LinkedIn for practical insights into engineering leadership, agile delivery, and team performance.