Module 5: Remote Machines, Networking, and Scripting
6. Your first shell script
Description
By the end of this lesson you will be able to take a sequence of commands you have already typed by hand two or three times and turn it into your own .sh file: with a portable shebang, the right execute permission, variables that do not break with one extra space, quotes that expand what you want and protect what you do not, parameters your script receives from the command line, a way to ask the user for a value when you need one, and controlled output with echo or printf.
This is exactly what separates someone who "knows how to use the terminal" from someone who uses it to build tools. Every time you connect to a server with ssh, run an rsync with the same five flags, and then check the date and who you are — and repeat it the following week typing it line by line again — you have a perfect candidate for a script. A script is not just "saving yourself typing": it is a procedure you can share with a teammate, version in Git, and trust to do exactly the same thing the tenth time as the first.
Connection to the module: in the previous lesson you solved the problem of a remote process dying when you close your laptop — tmux and nohup gave you persistence. Today you solve a different problem: you stop typing the same sequence of commands by hand every time and turn it into a reproducible file. The script you write today runs top to bottom, in a straight line, without making any decisions yet — that is exactly what arrives in the next lesson, with if, loops, and exit codes of your own.
From a repeated pipeline to an executable file
Think about the difference between explaining a recipe to someone from memory, out loud, every time they cook it — and risking forgetting a step or accidentally changing the order — versus writing it down once on a card that anyone can follow exactly the same way, today and a year from now. A shell script is that card: the same commands you already typed, saved in a file, in the same order, ready to run with a single command.
Every bash script starts with a special line called a shebang: #! followed by the path to the interpreter that should run it. When the operating system sees a file that starts with #!, it does not guess what to do with the rest of the file — it reads that first line and hands the whole file over to the program named there. You are going to write that line like this, always, in every bash script you write:
#!/usr/bin/env bash
And not like this, even though it also works on many systems:
#!/bin/bash
The difference matters more than it looks. #!/bin/bash assumes bash lives exactly at /bin/bash, which is true on most Linux distributions, but not universal. #!/usr/bin/env bash, instead, asks env to search for bash by walking your PATH variable and use the first one it finds — the same lookup mechanism the terminal uses when you type just bash. This matters especially on macOS: Apple froze the system /bin/bash at version 3.2 more than a decade ago (the last version under the GPLv2 license; newer bash versions use GPLv3, and Apple does not ship that). If you install a modern version of bash with Homebrew, that new version ends up at a different path (typically /opt/homebrew/bin/bash), and only #!/usr/bin/env bash is going to find it first, because Homebrew puts that path at the front of your PATH. With a fixed #!/bin/bash, your script keeps running with the system's old bash even if you have a newer one installed.
Worked example
Imagine you already ran these three commands, one by one, to check what machine you are on:
whoami
hostname
date
What to expect (the exact values are going to vary by machine):
alex
alexs-macbook.local
Tue Jul 21 09:14:32 -05 2026
Now turn those three commands into a file. Create it with your editor or with printf:
printf '%s\n' \
'#!/usr/bin/env bash' \
'# server-info.sh - prints who you are, on what machine, and when, at a glance.' \
'' \
'whoami' \
'hostname' \
'date' \
> server-info.sh
Give it execute permission (this picks back up chmod from lesson 3 of the previous module) and run the script:
chmod +x server-info.sh
./server-info.sh
What to expect:
alex
alexs-macbook.local
Tue Jul 21 09:14:32 -05 2026
Same result as typing the three commands by hand — but now it is a file you can run with a single command, share with someone else, or save in a repository.
Three ways to run the same file
chmod +x is not a decorative detail: it is what lets the operating system treat your file as a program instead of as plain text, and it connects directly to what you already saw about the x bit in the permissions lesson. But the execute permission is not the only way to run a script, and it is worth telling apart the three you are going to run into.
| Way | What it needs | What it uses |
|---|---|---|
./script.sh | Execute permission (x) on the file | Reads the shebang and hands the file to that interpreter |
bash script.sh | Only read permission | Ignores the shebang entirely — you already chose the interpreter by typing bash |
sh script.sh | Only read permission | Runs the file with sh, which on many Linux systems (Debian, Ubuntu) is not bash but dash, a more limited shell |
The sh row is not a minor detail: if your script uses bash-specific features (like the ones you are going to see in this very lesson) and someone runs it with sh script.sh on a system where sh is dash, some things are going to fail or behave differently, with your file's shebang never getting a chance to prevent it — because sh script.sh decides the interpreter before the shebang ever gets read.
Worked example
Remove the execute permission from the script you just created and see what breaks and what does not:
chmod 644 server-info.sh
./server-info.sh
What to expect:
-bash: ./server-info.sh: Permission denied
Without the x bit, the system refuses to even try. But invoking it explicitly with bash still works, because it only needs to be able to read the file:
bash server-info.sh
What to expect:
alex
alexs-macbook.local
Tue Jul 21 09:14:32 -05 2026
Give it back the execute permission so you can keep using it as in the rest of the lesson:
chmod +x server-info.sh
Variables: assignment without spaces, and the quotes that actually matter
A variable in bash stores a value under a name you can reuse. The syntax is rigid on one point that surprises almost everyone the first time: it never has spaces around the =.
user_name="Alex" # correct
user_name = "Alex" # error
The second line fails because bash does not see an assignment: it sees the command user_name, followed by the arguments = and Alex. Bash tries to run a program called user_name, does not find it, and hands you back command not found. Without spaces, bash recognizes the name=value pattern as an assignment; with a space in between, it stops recognizing it as one.
To read the stored value, you prefix the name with $ ($user_name, or ${user_name} when you need to make clear where the name ends, for example before appending text with no space in between: ${user_name}_backup). And here comes the part that really matters: almost every reference to a variable should be inside double quotes. Double quotes expand variables and command substitutions, but treat the result as a single piece of text, spaces included. Without quotes, bash goes back to splitting that value into separate words wherever it finds a space — the same mechanism that separates the arguments you type in the terminal.
Single quotes are even stricter: they expand absolutely nothing, not variables, not substitutions. Everything inside single quotes is literal text, character for character.
Worked example
Create a file with a space in the name — a more common case than it sounds (exported reports, files downloaded from the browser):
file_name="my report.txt"
touch "$file_name"
ls
What to expect:
my report.txt
A single file, with the space included in the name, exactly as you expected. Now repeat the same touch without the quotes:
rm "my report.txt"
touch $file_name
ls
What to expect:
my report.txt
Two files: my and report.txt. Without quotes, bash split the value of $file_name into two words because it found a space inside it, and touch received two arguments instead of one. Clean up before continuing: rm my report.txt.
Now compare double quotes against single quotes with the same variable:
echo "Hello, $user_name"
echo 'Hello, $user_name'
What to expect:
Hello, Alex
Hello, $user_name
The double quote expanded $user_name to its value. The single quote left it exactly as written, literal text — it did not even recognize there was a variable there.
Capturing a command's output: substitution with $( )
So far your scripts only print a command's output directly to the screen. But often you want to save that output in a variable, to use it later — in a message, in a filename, whatever it is. That is what command substitution is for: $(command) gets replaced with whatever that command prints to its standard output, without the trailing newline.
today="$(date +%F)"
You are also going to run into the older backtick syntax (`command`), which does the same thing, but $( ) is the preferred form today: it nests without ambiguity ($(echo "today: $(date +%F)") works with no tricks), while nesting backticks inside other backticks requires escaping characters and gets hard to read.
Worked example
Save the result of three different commands into variables and combine them into a single message:
today="$(date +%F)"
user_name="$(whoami)"
host_name="$(hostname)"
echo "$user_name connected to $host_name on $today"
What to expect:
alex connected to alexs-macbook.local on 2026-07-21
Each $( ) ran its command separately, captured the text it printed, and bash inserted it right where you wrote the substitution — just as if you had typed the value by hand, but computed at the exact moment the script runs.
Positional parameters: what your script receives from the command line
A script becomes much more useful when it does not have everything hardcoded inside it, but instead receives information from whoever calls it. Bash automatically stores the arguments you passed when invoking the script in special variables called positional parameters:
| Parameter | What it holds |
|---|---|
$0 | The name the script was invoked with (for example, ./server-info.sh) |
$1, $2, $3… | The first argument, the second, the third — in the order you wrote them |
$# | The total number of arguments received |
$@ | All the arguments, each one as a separate word |
"$@" (inside double quotes) is the form you almost always want when you need to forward the full set of arguments to another command later on: it preserves each argument as its own word, even if one of them has spaces inside it — the same care around quotes you already saw with regular variables, applied to a whole group of values.
Worked example
Create a script that just reports what it received, without doing anything else with that information yet:
printf '%s\n' \
'#!/usr/bin/env bash' \
'# args-demo.sh - shows what arrives from the command line.' \
'' \
'echo "Script name: $0"' \
'echo "First argument: $1"' \
'echo "Number of arguments: $#"' \
'echo "All arguments: $@"' \
> args-demo.sh
chmod +x args-demo.sh
./args-demo.sh production alex 42
What to expect:
Script name: ./args-demo.sh
First argument: production
Number of arguments: 3
All arguments: production alex 42
$1 picked up exactly the first word you typed after the script name (production), without you having to ask for it — bash had already split them apart and stored them before your script even started running.
Interactive input with read
Positional parameters work when whoever calls the script already knows what arguments to give it. But sometimes you want to ask the user for a value on the spot, while the script runs — without forcing them to memorize the order of the arguments. That is what the read command is for: it reads a line from standard input and stores it in the variable you tell it to.
read -r -p "What is your name? " user_name
The -p flag shows the quoted text as a prompt, on the same line where the user is going to type, with no need for a separate echo beforehand. The -r flag (for raw) is a good habit almost always: without it, read treats the backslash (\) as an escape character inside whatever the user typed, which is almost never what you want when you are simply asking for a name or a path.
Worked example
printf '%s\n' \
'#!/usr/bin/env bash' \
'# ask-name.sh - asks the user for a value instead of receiving it as an argument.' \
'' \
'read -r -p "What is your name? " user_name' \
'echo "Hello, $user_name"' \
> ask-name.sh
chmod +x ask-name.sh
./ask-name.sh
What to expect (the script stops and waits for you to type something; here it shows what you typed after the prompt):
What is your name? Alex
Hello, Alex
The script sat paused exactly at the read line, until you typed Alex and pressed Enter. From then on, $user_name holds that value just as if you had assigned it with =.
echo versus printf
echo is the simplest command for printing text, and for a fixed message with nothing special inside it, it works perfectly well. The problem shows up in two specific situations. First, echo does not interpret escape sequences like \t or \n by default in bash (you need the -e flag for that), but other shells do interpret them by default — in particular dash, which on many Linux distributions is the real /bin/sh. A script that assumes bash's echo behavior can print different text if someone runs it with sh script.sh instead of bash script.sh, tying back to the difference you saw earlier in this same lesson.
Second, and trickier: echo can confuse the value of a variable with one of its own flags, because by the time echo receives the argument, the quotes are already gone and it only sees plain text.
printf has neither problem: its behavior follows the same standard on every system, and it strictly separates the format ('%s\n') from the data you pass afterward, so it never confuses a value with an option.
Worked example
value="-n"
echo "$value"
What to expect:
Nothing. Not the text -n, not even a newline. Bash interpreted that argument as echo's -n flag (suppress the trailing newline), not as the text you wanted to print — even though it came from a variable inside double quotes, because by the time echo receives it, it is simply the text -n, with no trace left that it was ever quoted. Now the same variable with printf:
printf '%s\n' "$value"
What to expect:
-n
printf never interprets the second argument as one of its own options — the format ('%s\n') comes first and separate, and everything that follows is treated as data, no exceptions. That is why, when you are about to print the value of a variable (instead of fixed text you typed yourself), printf '%s\n' "$variable" is the safer choice.
Comments and a header that explains itself
Any line that starts with # is a comment — bash ignores it completely when running, except for the very first line of the file, where #! is the shebang, not a regular comment. A good habit, which you already used in every script in this lesson without naming it yet, is to open with a two- or three-line header that explains, without anyone having to ask: what the script does and how it is invoked.
#!/usr/bin/env bash
#
# session-log.sh — logs who connected, from which machine, and when.
# Usage: ./session-log.sh <environment>
# Example: ./session-log.sh production
That header costs three lines to write today and saves minutes for whoever opens the file six months from now — including you.
Worked example
Put together everything you saw in this lesson into a single script: shebang, header, variables, command substitution, a positional parameter, and read, closing with printf for the output.
#!/usr/bin/env bash
#
# session-log.sh — logs who connected, from which machine, and when.
# Usage: ./session-log.sh <environment>
# Example: ./session-log.sh production
environment="$1"
user_name="$(whoami)"
host_name="$(hostname)"
today="$(date +%F)"
read -r -p "Reason for connecting: " reason
printf 'Environment: %s\n' "$environment"
printf 'User: %s\n' "$user_name"
printf 'Machine: %s\n' "$host_name"
printf 'Date: %s\n' "$today"
printf 'Reason: %s\n' "$reason"
Save it, give it execute permission, and run it:
chmod +x session-log.sh
./session-log.sh production
What to expect (the script pauses at read; here it shows what you typed):
Reason for connecting: check deployment logs
Environment: production
User: alex
Machine: alexs-macbook.local
Date: 2026-07-21
Reason: check deployment logs
Every piece from this lesson shows up here doing its job: $1 brought production in from the command line, the three $( ) captured system data at the exact moment the script ran, read stopped to wait for a value only the user could give, and printf printed everything with a predictable format, with no surprises from misread flags.
Common mistakes
"I ran my script without the argument and it did not flag any error — the message just came out incomplete." What happens: in bash, a variable or positional parameter that was never set is not an error by default — it silently becomes an empty string. If you run ./session-log.sh with no arguments at all, $1 does not blow up: it simply equals "", and your script keeps running all the way to the end with an empty Environment: field instead of stopping to warn you something was missing. Why it happens: bash prioritizes letting scripts keep running even with incomplete data, unless you explicitly turn on a stricter mode. How to spot it: check the full output for empty fields where you expected a value, not an error message. How to fix it for now: clearly document in the script's header what arguments it expects and in what order; the way to validate that automatically with if and stop the script with your own exit code is exactly the topic of the next lesson.
"I put the text in single quotes thinking it would still show my variable's value." What happens: echo 'Hello, $user_name' literally prints Hello, $user_name, dollar sign, variable name and all, instead of Hello, Alex. Why it happens: single quotes expand absolutely nothing — not variables, not command substitutions, nothing that starts with $. They are the right choice only when you want completely literal text, including any $ that shows up inside it. How to spot it: the output shows the $ symbol and the variable name instead of its value. How to fix it: switch to double quotes (echo "Hello, $user_name") whenever you need something inside to expand.
"I gave it execute permission with chmod +x, but when someone else ran it with sh my_script.sh it behaved differently." What happens: chmod +x and the shebang only take effect when the script is invoked as ./my_script.sh. If someone runs it explicitly with sh my_script.sh, the shebang never gets read — and on several Linux distributions (Debian, Ubuntu), sh is not an alias for bash: it is dash, a more limited shell that does not support several of the features you used in this lesson. Why it happens: typing sh file or bash file explicitly chooses the interpreter, never going through your file's #! line at all. How to spot it: if the same script produces different results between two people, ask exactly which command they invoked it with, not just whether they gave it execute permission. How to fix it: always invoke it with ./script.sh so the shebang you wrote gets respected, or if you need to be explicit, use bash script.sh — never sh script.sh for a script that uses bash features.
Exercises
1. From loose commands to a script
You have this sequence, which you already ran by hand twice this week:
df -h
uptime
Write a script called health-check.sh with a portable shebang, a header comment explaining what it does, execute permission, and that when run with ./health-check.sh prints the output of both commands.
See solution
printf '%s\n' \
'#!/usr/bin/env bash' \
'# health-check.sh - shows disk space and machine uptime.' \
'' \
'df -h' \
'uptime' \
> health-check.sh
chmod +x health-check.sh
./health-check.sh
Why it works: the shebang #!/usr/bin/env bash tells the system which interpreter to use by searching the PATH; chmod +x gives the file the execute bit that ./health-check.sh needs to run it directly; and once the system hands the file over to bash, the commands run in the same order you wrote them, exactly as if you had typed them by hand.
2. Find the quoting bug
This snippet fails when $backup_name contains a space:
backup_name="daily backup.tar.gz"
cp report.tar.gz $backup_name
What exact error are you going to see, and how do you fix it?
See solution
cp is going to fail with something like cp: target 'backup.tar.gz' is not a directory (or an equivalent error), because without quotes bash splits $backup_name into two words — daily and backup.tar.gz — and hands them to cp as if they were two separate arguments instead of one. cp interprets that as "copy report.tar.gz and daily into a directory called backup.tar.gz," which does not exist.
The fix is to add double quotes around the variable:
cp report.tar.gz "$backup_name"
Why it works: double quotes preserve the variable's full value as a single word, spaces included, instead of letting bash split it again using the same rule it uses to separate the arguments you type in the terminal.
3. Predict the output
You have this script saved as report.sh:
#!/usr/bin/env bash
echo "Script: $0"
echo "Args received: $#"
echo "Second argument: $2"
If you run it like this: ./report.sh staging alex, what does each line print?
See solution
Script: ./report.sh
Args received: 2
Second argument: alex
Why it works: $0 always holds the name the script was invoked with, exactly as you typed it (./report.sh); $# counts how many arguments follow the script name (two: staging and alex); and $2 picks specifically the second of those arguments in the order you wrote them, neither the first nor the total.
4. The value disguised as a flag
This snippet should print the value of $flag, but when flag equals -e, it does not print what you expected:
flag="-e"
echo "$flag"
Why does this happen, and how do you fix it so it always prints the value exactly as it is, no matter what text it holds?
See solution
echo "$flag" ends up, in practice, running echo -e with no text after it — bash hands -e to echo as an argument, and echo interprets it as its own flag to turn on escape-sequence interpretation, not as the text you wanted to show. The result is that you do not see -e printed on screen, but the flag's behavior instead (in this case, with no extra text, it basically prints nothing useful).
The fix is to use printf instead of echo:
printf '%s\n' "$flag"
Why it works: printf strictly separates the format ('%s\n') from the data that follows it; everything arriving after the format is always treated as literal data, never as one of printf's own options, no matter what text it contains.
Summary and next step
Before moving on you should be able to:
- write the shebang
#!/usr/bin/env bashand explain why it is preferred over a fixed path like#!/bin/bash; - tell apart when a script runs with
./script.sh(needsxand respects the shebang),bash script.sh(ignores the shebang), orsh script.sh(may be a different shell, likedash); - declare variables with no spaces around the
=, and explain why quoting is almost always double quotes and not single quotes; - capture a command's output with
$( ), read positional parameters ($0,$1,$#,$@), and ask the user for a value withread -r -p; - choose
printfoverechowhen printing a variable's value, instead of fixed text you typed yourself.
Today your script runs in a straight line: it reads arguments, captures data, asks for input, prints results — but it never decides anything. It does not check whether you gave it the argument it expected, it does not tell apart a file that exists from one that does not, it does not retry or stop with its own error code when something goes wrong. That is exactly what the next lesson solves: if, case, for loops and while read, exit with documented codes, set -euo pipefail explained flag by flag, and the honest criterion for when a script has already grown too big and it is time to rewrite it in Python.
Resources
- Shell Parameters — GNU Bash Reference Manual — official reference for positional and special parameters (
$0,$1,$#,$@,$*). - Quoting — GNU Bash Reference Manual — the exact difference between single quotes, double quotes, and no quotes, straight from the source.
- Command Substitution — GNU Bash Reference Manual — how
$( )works and why it is preferred over backticks. - Bash Builtin Commands — GNU Bash Reference Manual — official documentation for
read(including the-pand-rflags) andprintf. - env(1) — Linux manual page — exactly what
envdoes when it searches yourPATHfor a program, the technical basis for the#!/usr/bin/env bashshebang. - printf(1) — Linux manual page — full syntax of the format sequences (
%s,%d, etc.) thatprintfaccepts.