Lab lesson · free
Learn to write Bash scripts (with exercises)
A Bash script bundles commands you'd otherwise type one by one into a repeatable file. That's exactly what makes admins fast. Here you write your first script step by step – and run it directly on a real VM.
Shebang and executability
Every script starts with #!/bin/bash – the shebang that tells the system what runs the file. chmod +x makes it executable. This is exactly where Bash connects to file permissions.
Variables and arguments
You store values in variables (name="value"); inputs reach the script via $1, $2. That turns a rigid sequence into a flexible tool.
Loops and conditions
With for/while loops and if conditions you automate repetition and make decisions – the basis of any real automation.
Why a script fails differently from a command
A script runs without an audience. What you see instantly in a terminal – an error, a prompt – vanishes without trace in a cron run. That is why set -euo pipefail belongs at the top: -e aborts on an error, -u on an unset variable, pipefail also when only one part of a pipe fails. Without that line a broken script cheerfully continues and reports success at the end.
Quotes are not cosmetic
rm $file and rm "$file" are two different commands as soon as the filename contains a space: without quotes the shell splits the value into several arguments. The same rule applies to $@ versus "$@". Almost every data loss caused by a bash script comes down to a missing quote.
Separate the output: stdout versus stderr
A script has two output channels. Results belong on stdout, progress messages on stderr – written with echo "…" >&2. The difference matters as soon as someone processes the output: result=$(my_script) captures only stdout, and your progress notes do not end up in the middle of the data.
Clean up, even when it goes wrong
If your script creates a temporary file, it must disappear on abort too. trap 'rm -f "$tmp"' EXIT attaches a cleanup to the end of the script – whether it finishes normally, aborts on set -e, or is killed with Ctrl + C. Without trap, a regularly scheduled script quietly fills /tmp over months.
Exit codes: how a script signals success
Every command leaves an exit code: 0 means success, anything else failure. $? holds the last one. && and || build on it, and so does whoever calls your script – a cron job, a monitor, a delivery pipeline. A script that returns 0 despite an error reports success for something that did not happen. That is why a deliberate exit 0, or a fitting value, belongs at the end.
What usually goes wrong
The most common mistake is a script that only works in its own directory: relative paths break the moment cron starts it from /. The second is a pipe whose first element fails – without pipefail only the last one counts, and false | echo ok passes as success. The third is carelessness when deleting: rm -rf "$dir/"* with an empty variable points at the root directory.
From one-liner to reusable tool
A script becomes useful once it can explain itself. A short usage() function, printed on missing arguments or on -h, saves you from reading your own source in three months. Speaking variable names and functions instead of copy-and-paste belong to it: whoever has the same block three times in a script has to change it three times on the next fix and will forget one place. That is not a matter of style but the difference between a script that survives in operation and one that gets replaced after the first change.
Commands to try
Create a script and make it executable
$ nano backup.sh$ chmod +x backup.sh$ ./backup.sh /tmp/dataWithout chmod +x the shell answers Permission denied even though the file is readable – the execute bit is separate.
The skeleton that makes every failure visible
$ #!/usr/bin/env bash$ set -euo pipefail$ target="${1:?give a path}"$ echo "backing up to $target"${1:?…} aborts with a readable message when the argument is missing – better than an empty path that quietly points at the root directory.
Loop over files without tripping on spaces
$ for f in *.log; do$ [ -e "$f" ] || continue$ echo "$f: $(wc -l < "$f") lines"$ doneThe second line catches the case where no file matches – otherwise the loop runs once with the literal pattern *.log as the name.
Temporary file with guaranteed cleanup
$ tmp=$(mktemp)$ trap 'rm -f "$tmp"' EXIT$ sort input.txt > "$tmp"$ mv "$tmp" output.txtmktemp creates a collision-free name. The closing mv is atomic – either the complete file is there or the old one, never half of it.
Common error messages
- bash: ./backup.sh: Permission denied
- Cause: The file lacks the execute bit – reading it is still allowed.
- Fix:
chmod +x backup.sh. Alternativelybash backup.sh, which starts the interpreter on the file directly. - ./backup.sh: line 3: $'\r': command not found
- Cause: The file has Windows line endings (CRLF). The trailing carriage return is read as part of the command.
- Fix: Convert with
sed -i 's/\r$//' backup.shor set the editor to LF. - backup.sh: line 7: target: unbound variable
- Cause:
set -uis doing its job: the variable was used but never set – usually a typo in the name. - Fix: Check the name or set a default:
target="${1:-/tmp}". - syntax error near unexpected token `fi'
- Cause: An
ifwithout a closingthenon the same or next line, or a missing semicolon beforethen. - Fix: Check with
bash -n script.shwithout running it. The reported line number points at the CLOSING word; the mistake is usually above it.
In five steps
- 01Create the file and start with
#!/usr/bin/env bash– not the hard-coded/bin/bash. - 02Put
set -euo pipefailon the second line, before anything happens. - 03Read one argument and guard it against being forgotten with
${1:?…}. - 04Make it executable with
chmod +xand run it once without an argument – the error must be readable. - 05Check the syntax with
bash -n script.shwithout running it; that belongs before every real run.
What you practise
Get in Touch
Have a project?
Let's bring your idea to life together. We're happy to advise you with no obligation.
Get in Touch →