Skip to content

Phase 1 · FOUNDATIONS

Shell scripting 1 — variables, conditionals, loops

Day 15 of 90 ~55 min 0/20 in phase Builds on Day 14

By the end of today

  • Write a bash script with a shebang, variables and command substitution
  • Branch on conditions using if and [[ ]] file tests
  • Repeat with for and while loops, then make the script executable

Shell scripts: a file of commands the shell runs top to bottom

Section 1 of 5 · ~3 min

Everything you have typed at the prompt for two weeks — ls, grep, cd — a shell script simply puts in a file so the machine runs it for you, in order, the same way every time. That is the whole idea, and it is the foundation of every deploy script, health check, and CI step you will ever write.

A script needs four pieces to graduate from a pile of commands into a program.

The shebang. The first line, #!/usr/bin/env bash, tells the kernel which interpreter should read the rest of the file. Leave it off and a script that works in your terminal can break in a cron job or CI runner that defaults to a different shell. Prefer env bash over a hard-coded /bin/bash — it finds bash wherever it is installed.

Variables and quoting. name="pushkar" stores a value; $name reads it back — with no spaces around the =, which is the number-one beginner mistake. The habit that saves you: always double-quote your variables, as in "$name", so a value containing a space stays one word instead of splitting into two arguments. Unquoted $file where the name is my report.txt becomes two arguments and the command misfires.

Command substitution. $(command) runs a command and drops its output straight into your line. today=$(date +%F) captures today’s date; count=$(ls | wc -l) captures a count. This is how a script reacts to the live system instead of to hard-coded values.

How a script runs: you launch ./script.sh, the shebang picks bash, bash reads the variables and command substitutions, then the if and for control flow decides and repeats, producing the result. ./script.sh shebang picks bash variables read & $( ) if / for decide, repeat result
Four pieces turn a pile of commands into a program: shebang, variables, and the control flow that decides and repeats.

Real world: A shell script is a recipe card. The shebang names which kitchen it is written for; variables are the labelled jars of ingredients; command substitution is “taste the sauce and add however much salt it needs right now”; and the control flow below is “if the dough is sticky, add flour” and “pipe icing onto each of the twelve cupcakes.” Anyone can follow the card and get the same dish.

Conditionals and loops: making the script decide and repeat

A straight-line script is useful; one that reacts is powerful. Conditionals run commands only when a test passes:

if [[ -f /etc/nginx/nginx.conf ]]; then echo "present"; fi

The modern test brackets are [[ ... ]] — bash’s built-in, safer than the older [ ]. Common tests: -f (a file exists), -d (a directory exists), -z (a string is empty), and "$a" == "$b" for equality. else and elif handle the other branches.

Loops repeat work. A for loop walks a list — for f in *.log; do gzip "$f"; done compresses every log file. A while loop runs as long as a condition holds — ideal for “wait until the service answers.” Between them they cover almost every repeat-until-done task in operations.

A named example: the official Docker install script you fetch with curl -fsSL https://get.docker.com | sh is one large bash file — a shebang, variables that detect your distro, if branches for Ubuntu versus Fedora, and loops over packages. Millions of installs run it, and it is not magic — it is exactly the four pieces above, at scale.

Today you write your own: small, but with every moving part.

Hands-On Lab

Section 2 of 5 · ~3 min

Budget about 25 minutes. Open your WSL2 Ubuntu 24.04 terminal (if you have not set up WSL2 yet, do Day 0 first — macOS and Linux users can follow along in their built-in terminal). You will build a couple of tiny scripts and run them — type every line yourself and read every line of output.

# 1. Create your first script: shebang, a variable, and command substitution.
#    The quoted 'EOF' keeps $name and $(date) literal in the FILE (expanded only when run).
cat > ~/hello.sh <<'EOF'
#!/usr/bin/env bash
name="pushkar"
today=$(date +%F)
echo "Hello, $name - today is $today"
EOF
cat ~/hello.sh
# Output:
# #!/usr/bin/env bash
# name="pushkar"
# today=$(date +%F)
# echo "Hello, $name - today is $today"
# 2. Run it through bash directly — no execute bit needed. Watch the variables expand.
bash ~/hello.sh
# Output (your date will differ):
# Hello, pushkar - today is 2026-07-10
# 3. Make it runnable on its own: chmod +x adds the execute bit, then ./ runs it.
chmod +x ~/hello.sh
cd ~ && ./hello.sh
# Output:
# Hello, pushkar - today is 2026-07-10
# 4. Confirm the execute bit is set — the x's in the permission string say "runnable".
ls -l ~/hello.sh
# Output (the rwx: owner may read, write, execute):
# -rwxr-xr-x 1 pushkar pushkar 91 Jul 10 10:12 /home/pushkar/hello.sh
# 5. Write a script that DECIDES: does a path exist? [[ -f ]] tests for a regular file.
cat > ~/check.sh <<'EOF'
#!/usr/bin/env bash
target="/etc/os-release"
if [[ -f "$target" ]]; then
  echo "$target exists"
else
  echo "$target is missing"
fi
EOF
bash ~/check.sh
# Output:
# /etc/os-release exists
# 6. Point the same test at a path that does NOT exist — the else branch fires.
#    sed rewrites the target on the way to bash; the file on disk is untouched.
sed 's#/etc/os-release#/etc/nope.conf#' ~/check.sh | bash
# Output:
# /etc/nope.conf is missing
# 7. A for loop walks a known list. Loop over three service names, print a line each.
for svc in nginx ssh cron; do echo "checking $svc"; done
# Output:
# checking nginx
# checking ssh
# checking cron
# 8. Loop over REAL files: make three, then let a glob feed the loop (expands alphabetically).
touch ~/a.log ~/b.log ~/c.log
for f in ~/*.log; do echo "found $f"; done
# Output:
# found /home/pushkar/a.log
# found /home/pushkar/b.log
# found /home/pushkar/c.log
# 9. A while loop repeats WHILE a condition holds. Count down from 3; $(( )) does the math.
n=3
while [[ $n -gt 0 ]]; do echo "n = $n"; n=$((n - 1)); done
# Output:
# n = 3
# n = 2
# n = 1
# 10. Why quoting matters: a value with a space stays ONE argument when double-quoted.
file="my report.txt"
touch "$file"
ls
# Output (modern ls quotes the space-name; quoting kept it a single file):
# a.log  b.log  c.log  check.sh  hello.sh  'my report.txt'

Read the last outputs back: you wrote a file with a shebang, ran it two ways, made it decide with if [[ ]], repeated work with for and while, and saw why quoting keeps a value in one piece — the four pieces of every script you will write from here on.

Common Errors & Fixes

Section 3 of 5 · ~2 min

These three trip up almost everyone writing their first scripts. Read the error text slowly — learning to parse it is the actual skill.

Common error: Adding spaces around the = in an assignment — writing name = "pushkar" — prints:

bash: name: command not found

Why: In bash, an assignment must have no spaces around the =. With spaces, the shell reads name as a command to run, with = and "pushkar" as its arguments — and there is no program called name, so it fails.

Fix: Remove the spaces: name="pushkar". The same rule applies to every assignment, including inside for and while.

How you’d spot it in prod: command not found on what should be a variable in a deploy log almost always means a stray space around an =. The tell is the error naming your variable as the missing command — grep the script for = and = .

Common error: Running a script with ./ before making it executable — ./deploy.sh — prints:

bash: ./deploy.sh: Permission denied

Why: ./deploy.sh asks the kernel to execute the file directly, but the file has no execute bit set, so permission is refused. Running it as bash deploy.sh works because there you hand the file to an already-running interpreter — no execute bit required.

Fix: Add the execute bit once: chmod +x deploy.sh, then ./deploy.sh works. Confirm with ls -l — you should see -rwxr-xr-x.

How you’d spot it in prod: A script that runs fine locally but hits Permission denied in CI usually lost its execute bit in a fresh checkout or an unzipped artifact. Either chmod +x it in the pipeline or invoke it explicitly as bash script.sh.

Common error: Editing a script on Windows, then running it in WSL2 where it picked up Windows CRLF line endings — ./deploy.sh — prints:

/usr/bin/env: 'bash\r': No such file or directory
/usr/bin/env: use -[v]S to pass options in shebang lines

Why: Windows editors save lines ending in \r\n (CRLF); Linux expects just \n. The trailing \r gets glued onto the shebang, so the kernel asks env for an interpreter literally named bash\r, which does not exist.

Fix: Strip the carriage returns: sed -i 's/\r$//' deploy.sh (or dos2unix deploy.sh). Set your editor to LF line endings for .sh files so it does not come back.

How you’d spot it in prod: The giveaway is the \r in the error message, or ^M$ at the end of every line when you run cat -A deploy.sh. It shows up the moment a Windows-authored script reaches a Linux runner.

Shell Scripting Interview Questions

Section 4 of 5 · ~1 min

Cover the answers below and say your own version out loud first — explain the shebang, quoting, and when you would loop before you reveal each answer. Recalling before revealing is what makes these stick when an interviewer asks them cold. The four questions and answers render right after this note.

Go Deeper

Section 5 of 5 · ~1 min

Optional extras if you have ~25 more minutes today:

  • 5 min — Run help test in bash and skim the file-test operators: -f, -d, -r, -x, -z, -n. These are the conditions your if statements will lean on for the rest of the mission.
  • 10 min — Paste your check.sh into ShellCheck (or apt install shellcheck and run shellcheck check.sh). It flags unquoted variables and other traps before they bite — treat it as a linter for every script you write.
  • 10 min — Read the Bash Fundamentals and Bash Control Flow sections of the Linux for DevOps guide for how variables, tests and loops fit the wider scripting picture.
Why put a shebang at the top of a shell script? Both

The shebang — the first line, like #!/usr/bin/env bash — tells the kernel which interpreter should run the rest of the file. Without it, whatever shell happens to invoke the script decides, so a script written for bash can break when a cron job or CI runner hands it to dash or sh instead. I always use #!/usr/bin/env bash rather than a hard-coded /bin/bash, because env finds bash wherever it is installed — that matters on Alpine or macOS where the path differs. The one-line rule I give in interviews: the shebang makes a script self-describing, so ./script.sh runs the same way no matter who launches it.

Why should you double-quote variables in bash? Both

Because an unquoted variable gets word-split and glob-expanded by the shell before the command ever sees it. If file holds 'my report.txt' and I write rm $file, the shell splits it into two arguments and rm tries to delete 'my' and 'report.txt' — not the file I meant. Quoting it, rm "$file", keeps the value as one argument. The same bug bites empty variables: an unquoted empty $x inside [ ] collapses the test and throws 'too many arguments'. My default is to quote every variable expansion unless I have a specific reason not to. It is the single habit that prevents the most shell-script bugs in production.

When would you use a for loop versus a while loop? Product

A for loop is for a list you already know — files, service names, arguments: for f in *.log; do gzip "$f"; done runs once per item and stops. A while loop is for a condition that changes as you go — you do not know how many iterations it takes. I reach for while when I am waiting on something: while ! curl -sf http://localhost:8080/health; do sleep 2; done polls until a service answers. So the rule is: for when you can count the items up front, while when you are looping until a condition flips. Getting that wrong is how people write for loops that should have been retry loops.

How do you capture the output of a command into a variable? Service

With command substitution: $(command). today=$(date +%F) stores the date; count=$(ls /var/log | wc -l) stores a number. The output replaces the $( ) inline, so scripts react to the live system instead of hard-coded values. I always quote the result when I use it — echo "there are $count files" — for the same word-splitting reason as any variable. The older backtick form works too, but $( ) nests cleanly and is what I write. On client work this is what makes one script reusable across projects: it reads hostnames, dates and counts from the machine it runs on, so the same file behaves correctly on every environment instead of being edited per box.

Mark Day 15 complete

Your script runs top to bottom today — tomorrow you break it into functions, check exit codes, and build a real backup script.

Stuck on today’s lab? Ask in Mission 90 Q&A