Module 3 closed by pointing a finger at a wall: the crontab line full of escapes, the report pipeline that no longer fitted on the screen, and that unbearable dependency on you remembering every step at three in the morning. This module tears that wall down with the simplest idea in the world: a script is nothing more than a text file containing the commands you already know how to type.

There is no new language to learn: grep, awk, find, curl and df are still the same. What changes is that they stop living in your memory and start living in a file with a name, permissions, an author and a date; a file that can be run identically a thousand times and run on its own while you sleep. This lesson lays the foundations and ends with health_check.sh, the first script of the running thread, which the next six lessons will keep improving.

Contents

  1. Does this deserve to be a script?
  2. What exactly a script is
  3. The shebang: who interprets your file
  4. The three ways to run a script
  5. Execute permissions
  6. Where scripts live, and the PATH
  7. Anatomy of a decent script
  8. Useful comments and useless comments
  9. Exit codes
  10. First real script: health_check.sh
  11. Style and tooling: shellcheck and shfmt

  1. Does this deserve to be a script?

Writing a script has a cost: it has to be maintained, versioned, documented, and you have to remember it exists. Before you open the editor, place what you want to do somewhere on this scale.

Form When Advantage Limit
One-liner / alias You do it once, or it is a fixed daily command Zero cost, or one line in .bashrc Lost when the session closes; an alias cannot take arguments in the middle
Shell function Short logic with arguments, just for you Lives in your environment, no subprocess Neither cron nor another user can run it
Script A repeatable procedure, with steps and decisions Versionable, shareable, schedulable Bash gets uncomfortable beyond ~300 lines
Ansible Configuring machines, and more than one Idempotency and inventory out of the box Requires infrastructure and learning
Programming language Data structures, JSON, concurrency, APIs Types, tests, libraries Dependencies to install and maintain

The practical rule: if you have done it three times by hand, or if somebody who is not you is going to run it, or if cron is going to run it, it is a script. And if on top of that it starts handling nested data or talking to JSON APIs, the alarm is going off and it is time to look towards Python; we will come back to that in 04-07. The health check Marta asks for every morning meets all three criteria at once: it is a script.

  1. What exactly a script is

A plain text file with commands that the interpreter reads and executes in order. It is not compiled, it is not linked, it produces no binaries.

operator@srv-tramontana:~$ printf 'echo "Hello"\ndate +%%F\n' > /tmp/first.sh
operator@srv-tramontana:~$ bash /tmp/first.sh
Hello
2026-08-18

bash opened the file, read the first line, ran it as if you had typed it, and moved on to the second. That complete equivalence between "what you type" and "what goes in a script" is the reason this module will feel natural to you: you already know how to write the content, all that was missing was the container.

  1. The shebang: who interprets your file

If the file is run on its own, the kernel needs to know which program should interpret it: that is what the first two characters, #!, followed by the path to the interpreter, say. When you run ./script.sh, the kernel reads those magic bytes and actually starts /bin/bash ./script.sh. Without a shebang the result depends on where you are, so never leave it to chance.

Shebang What it does When to use it
#!/bin/bash Fixed path to Bash Linux servers, where Bash is always there
#!/usr/bin/env bash Looks for bash in the PATH Portability: macOS, BSD, Bash in /usr/local
#!/bin/sh The system's POSIX shell Scripts that must run on any Unix, with no extensions

In this course we will use #!/usr/bin/env bash: it costs exactly the same and it saves you the day you run the script on a macOS where modern Bash lives in /opt/homebrew/bin/bash.

Why /bin/sh is not Bash on Ubuntu

On Ubuntu and Debian, ls -l /bin/sh reveals a link to dash, a tiny shell that implements POSIX and nothing else. It was chosen that way because there are thousands of boot scripts and dash runs them much faster. The consequence is that everything Bash-specific fails:

operator@srv-tramontana:~$ cat > /tmp/test_sh.sh <<'EOF'
#!/bin/sh
if [[ "bookings" == book* ]]; then
    echo "matches"
fi
EOF
operator@srv-tramontana:~$ bash /tmp/test_sh.sh
matches
operator@srv-tramontana:~$ sh /tmp/test_sh.sh
/tmp/test_sh.sh: 2: [[: not found

The same file, two interpreters, two results. [[ ]] belongs to Bash, and so do arrays, ${VAR^^}, local -n and <<<. And notice the cruel detail: the script did not fail when you wrote it, it failed when it was run with the wrong interpreter, possibly weeks later and inside cron. If you write #!/bin/sh, do not use anything from Bash.

  1. The three ways to run a script

There are three of them — bash script.sh starts a new Bash that reads the file, ./script.sh lets the kernel read the shebang, and source script.sh (or . script.sh, the POSIX form) has the current shell run it — and the difference is not cosmetic: it changes which process your commands run in.

flowchart LR
    A["Your interactive shell<br/>PID 3120, cwd=/home/operator"] -->|"bash script.sh<br/>./script.sh"| B["Bash subprocess<br/>PID 3455, a copy of the environment"]
    B -->|"when it ends, it dies"| C["The original shell<br/>has not changed"]
    A -->|"source script.sh"| D["Same process, PID 3120<br/>cd, variables and functions persist"]

The classic demonstration, a script that changes directory:

operator@srv-tramontana:~$ cat > /tmp/go_releases.sh <<'EOF'
#!/usr/bin/env bash
cd /opt/tramontana/releases
echo "Inside the script I am in: $PWD"
EOF
operator@srv-tramontana:~$ chmod +x /tmp/go_releases.sh
operator@srv-tramontana:~$ /tmp/go_releases.sh
Inside the script I am in: /opt/tramontana/releases
operator@srv-tramontana:~$ pwd
/home/operator
operator@srv-tramontana:~$ source /tmp/go_releases.sh
Inside the script I am in: /opt/tramontana/releases
operator@srv-tramontana:~$ pwd
/opt/tramontana/releases

The script did change directory... in its own process, which no longer exists. A child inherits a copy of its parent's environment, but nothing the child changes comes back to the parent: not the directory, not the variables, not the functions. It is the one-way inheritance from 03-01; with source there is no new process and that is why the cd persists.

Form New process? Does it need chmod +x? Does it respect the shebang? Effects that persist
bash script.sh Yes No No (bash decides) None
./script.sh Yes Yes Yes None
source script.sh No No No cd, variables, functions

Two practical rules. Scripts are run with ./ or by their name, the only form that respects the shebang and the one cron will use. And source is reserved for configuration and function libraries, which is exactly what we will use it for in 04-05. One warning: since source creates no process, an exit 1 inside a file you source in your terminal closes your session. It has happened to everybody once.

  1. Execute permissions

A text file is not executable until you tell the system so. Picking up 02-07 again:

operator@srv-tramontana:~/scripts$ ./health_check.sh
-bash: ./health_check.sh: Permission denied
operator@srv-tramontana:~/scripts$ chmod +x health_check.sh && ls -l health*
-rwxr-x--- 1 operator operator 512 Aug 18 09:14 health_check.sh

chmod +x respects the umask 027 from Module 2: it adds x for the user and the group, not for others; chmod 750 says the same thing without depending on the umask. Two nuances: the x bit does not mean "this is machine code" but "allow me to try to execute it" — what decides how to interpret it is the shebang — and bash script.sh works without the x bit, because there the file is merely read, which is the convenient way to try out something half-written.

  1. Where scripts live, and the PATH

Typing /home/operator/scripts/health_check.sh every morning guarantees nobody will use it. The solution is the PATH, which you already dissected in 03-01.

Location For whom Notes
~/scripts You, with an explicit path Where the source code lives in this course
~/bin or ~/.local/bin Your user only Ubuntu adds it to the PATH if it exists at login
/usr/local/bin All users The right place for local system scripts (/usr/bin belongs to the package manager: never put anything of yours there)
operator@srv-tramontana:~$ mkdir -p ~/bin ~/scripts
operator@srv-tramontana:~$ ln -s ~/scripts/health_check.sh ~/bin/health-check
operator@srv-tramontana:~$ source ~/.profile && which health-check
/home/operator/bin/health-check

We use a symbolic link instead of moving the file: the code stays in ~/scripts (versioned in git, in 04-07) and ~/bin merely publishes a convenient name, with no extension and with hyphens, like Unix commands. The Module 3 rule still stands: never . in the PATH, because it would be enough for somebody to leave a file called ls in a shared directory for you to run their code without knowing it.

  1. Anatomy of a decent script

This is the template you are going to start all your scripts with from today. Every line avoids a specific problem you will meet during the module.

#!/usr/bin/env bash
#
# name.sh - One line saying what it does.
#
# Author : Full Name <email>
# Date   : 2026-08-18
# Usage  : name.sh [options] <argument>
#
# A somewhat longer description if needed: what it checks, what it modifies,
# what it writes and where.

set -euo pipefail

# --- Constants ------------------------------------------------------------
LOG_DIR="/var/log/tramontana"
DISK_THRESHOLD=80

# --- Body -----------------------------------------------------------------
# ... the real work goes here ...
exit 0
  • Header. Name, purpose, author, date and usage: when you find this file at four in the morning eight months from now, those five lines will be worth more than everything else.
  • set -euo pipefail. Strict mode: -e aborts if a command fails, -u aborts if you use an undefined variable, and pipefail makes a pipeline fail if any of its stages fails, not just the last one (we announced it in 03-04). Put it in everything you write from today; the detailed why and — above all — where it does not work is the heart of 04-06.
  • Constants in UPPERCASE, lowercase for working variables, and all of them grouped at the top: changing a threshold must not force you to read the whole script.
  • An explicit exit 0. Without it, the script returns the code of the last command, which can be anything. exit 0 says "I got this far and everything went well", and that is what cron and the monitoring read.

  1. Useful comments and useless comments

Everything after # is ignored to the end of the line, except the shebang. But commenting a lot is not the same as commenting well.

# USELESS: repeats what the code already says
usage=$(df --output=pcent / | tail -1)   # assigns the disk usage to usage

# USEFUL: documents a decision that looks like a mistake
# We do not use a plain 'grep -c' because it returns 1 when there are no
# matches, and with 'set -e' that would abort the script on a quiet day.
errors=$(grep -c "^$today" "$ERROR_LOG" || true)

The rule: the code says what it does; the comment says why. If you need a comment to understand what a line does, the fix is usually to rewrite it or to extract it into a well-named function (04-05).

  1. Exit codes

Every process finishes returning an integer from 0 to 255. You already know it as $?; now you are the one producing it, and that changes the responsibility: the exit code is the only part of your script another program is going to read. The convention is the opposite of the intuitive one — 0 is success — and it makes sense: there is only one way to get it right and many ways to fail, each with its own number.

Code Meaning
0 Success
1 Generic error
2 Incorrect usage: missing arguments, unknown option
126 / 127 It exists but could not be executed (missing x bit) / command not found (typically, cron's PATH)
128+N Terminated by signal N: 130 = Ctrl+C, 143 = SIGTERM, 137 = SIGKILL

(And exit -1 ends up as 255, out of range.) exit with no argument returns the code of the last command: be explicit. And avoid inventing codes above 125, which clash with the reserved ones; the 3-125 range is more than enough for your own meanings, documented in the header. In 04-03 we will formalise a table for our scripts.

  1. First real script: health_check.sh

Marta asks for the same thing every morning: "is the site up, is there disk space left, and were there any errors overnight?". We write it exactly like that, as a run of commands, applying the template from the previous section.

#!/usr/bin/env bash
#
# health_check.sh - Quick health check for Tramontana Bookings.
#
# Author : Systems operator <operator@srv-tramontana>
# Date   : 2026-08-18
# Usage  : health_check.sh
#
# Checks three things: that the application answers on port 8080, how much
# disk is left on the root filesystem, and how many errors were logged today.
# It modifies nothing: it is read-only and safe to run in production.

set -euo pipefail

# --- Constants ------------------------------------------------------------
HEALTH_URL="http://10.0.2.15:8080/health"
ERROR_LOG="/var/log/tramontana/errors.log"
DISK_THRESHOLD=80
MAX_WAIT=5

# --- Body -----------------------------------------------------------------
echo "=== Tramontana Bookings health check ==="
echo "Server   : $(hostname)"
echo "Date     : $(date '+%F %T')"
echo

# -s silences the progress bar, -o /dev/null throws away the response body,
# -w prints only the code, --max-time stops it hanging.
http_code=$(curl -s -o /dev/null -w '%{http_code}' \
    --max-time "$MAX_WAIT" "$HEALTH_URL" || true)
echo "HTTP 8080   : $http_code"

# --output=pcent gives only the percentage column; tail -1 skips the
# "Use%" header; tr -dc '0-9' leaves a clean number to compare.
disk_usage=$(df --output=pcent / | tail -1 | tr -dc '0-9')
echo "Disk /      : ${disk_usage}% used (threshold ${DISK_THRESHOLD}%)"

# grep -c returns 1 when it finds nothing and with set -e that would abort
# the script on precisely the day everything is fine. Hence the || true.
today=$(date '+%Y-%m-%d')
errors_today=$(grep -c "^$today" "$ERROR_LOG" || true)
echo "Errors today: $errors_today"
exit 0
operator@srv-tramontana:~$ chmod 750 ~/scripts/health_check.sh
operator@srv-tramontana:~$ ~/scripts/health_check.sh; echo "code: $?"
=== Tramontana Bookings health check ===
Server   : srv-tramontana
Date     : 2026-08-18 09:22:41

HTTP 8080   : 200
Disk /      : 30% used (threshold 80%)
Errors today: 6
code: 0

It is already infinitely better than three commands in the history. But it is honestly bad, and it is worth seeing why from the start, because that list is the table of contents for the rest of the module:

  • The constants are inside the script and it accepts no options: no -h, no different threshold, no quiet mode (04-02 and 04-03).
  • It prints the threshold but compares it against nothing. There is no decision at all: if the disk were at 95% it would calmly say "95%" (04-04).
  • Three almost identical blocks that are crying out to be functions (04-05), and if errors.log does not exist the script dies halfway through with an ugly message (04-06).
  • It always returns 0, so no monitoring system can use it (04-07).

Save it as it is: at the end of the module you will compare it with its final version.

  1. Style and tooling: shellcheck and shfmt

Bash has a syntax full of traps and an interpreter that almost never complains: write $file without quotes and it will work for months, until the day a name with a space turns up. Static analysis is not optional in shell.

operator@srv-tramontana:~$ sudo apt install -y shellcheck shfmt
operator@srv-tramontana:~$ printf '#!/usr/bin/env bash\ndir=/opt/tramontana/releases\ncd $dir\nrm -f $1\n' > /tmp/bad.sh
operator@srv-tramontana:~$ shellcheck /tmp/bad.sh
In /tmp/bad.sh line 3:
cd $dir
   ^--^ SC2086: Double quote to prevent globbing and word splitting.
^-----^ SC2164: Use 'cd ... || exit' in case cd fails.
In /tmp/bad.sh line 4:
rm -f $1
      ^-- SC2086: Double quote to prevent globbing and word splitting.

Three warnings in four lines and all three are time bombs: without quotes, a directory with spaces is split into two arguments; and if the cd fails, the rm -f runs in whatever directory you happened to be in. That is SC2164. Every SCxxxx code has its page in the ShellCheck wiki; in 04-06 we will pick apart the four most frequent ones. The module's rule from today: no script is finished until shellcheck has nothing to say.

operator@srv-tramontana:~$ shellcheck ~/scripts/health_check.sh && echo "Clean"
Clean
operator@srv-tramontana:~$ shfmt -i 4 -d ~/scripts/health_check.sh   # -d: the diff only
operator@srv-tramontana:~$ shfmt -i 4 -w ~/scripts/health_check.sh   # -w: rewrites

shfmt formats instead of criticising: -i 4 sets four spaces, -d shows what it would change without touching anything (the course's --dry-run habit) and -w writes. On style, three decisions we will not discuss again: four spaces of indentation (never tabs, which break when copied), lines under 100 characters split with \, and one statement per line.

Common Mistakes and Tips

  • Writing the script on Windows and uploading it. CRLF line endings make the shebang #!/usr/bin/env bash\r, and the error, bad interpreter: No such file or directory, tells you nothing. Diagnose it with file script.sh and fix it with dos2unix.
  • Leaving lines before the shebang. It must be the first line, starting in column 1. And do not call your script test.sh: test is a builtin and the name clash causes headaches.
  • Running a script with a Bash shebang using sh script.sh. The shebang is ignored when you invoke the interpreter by hand: it is the number one cause of "it works in my terminal and fails on the server".
  • Expecting a script to change your directory or your variables. It cannot: it is another process. If you genuinely need that, it is a function or a file to source. And do not forget the final exit 0: if the last line is a grep with no matches, your perfectly correct script returns 1 and cron reports a failure that never happened.
  • Tip: while developing, bash -n script.sh checks the syntax without running anything; it costs a second and saves you from discovering a missing fi halfway through a deployment. Also integrate shellcheck into your editor, and start every script by copying the template from section 7.

Exercises

Exercise 1. Demonstrate empirically, with a single file and without editing it between tests, the difference between bash script.sh, ./script.sh and source script.sh. The script must define a variable, change directory and print its own PID. Explain what you observe in each case and why.

Exercise 2. Write ~/scripts/release_info.sh: which version of Tramontana is active (resolving /opt/tramontana/app), how much that release takes up and when it was deployed. Apply the complete template, leave it clean under shellcheck and publish it in ~/bin as release-info.

Exercise 3. Luis has left this script on the server. Find four distinct problems without running it, justify each one and rewrite it.

#! /bin/sh
# cleans temp files
cd /srv/tramontana/backups/temp
rm -rf *
if [[ $? == 0 ]]; then echo ok; fi

Solutions

Solution 1. $$ holds the PID of the shell running the code: it is the witness that gives away whether there was a new process.

operator@srv-tramontana:~$ cat > /tmp/demo_scope.sh <<'EOF'
#!/usr/bin/env bash
echo "Script PID       : $$"
MY_VARIABLE="defined inside"
cd /opt/tramontana/releases
echo "Directory inside : $PWD"
EOF
operator@srv-tramontana:~$ chmod +x /tmp/demo_scope.sh; echo "My shell: $$"
My shell: 3120
operator@srv-tramontana:~$ /tmp/demo_scope.sh
Script PID       : 3456
Directory inside : /opt/tramontana/releases
operator@srv-tramontana:~$ echo "$PWD - ${MY_VARIABLE:-(does not exist)}"
/home/operator - (does not exist)
operator@srv-tramontana:~$ source /tmp/demo_scope.sh
Script PID       : 3120
Directory inside : /opt/tramontana/releases
operator@srv-tramontana:~$ echo "$PWD - ${MY_VARIABLE:-(does not exist)}"
/opt/tramontana/releases - defined inside

bash /tmp/demo_scope.sh gives the same result as ./: a PID different from your shell's, therefore a new process, and the cd and the variable die with it. Only the mechanism changes: bash file starts Bash explicitly and ignores the shebang, whereas ./file makes the kernel read the shebang and decide — and that is why that case, and only that case, requires the x bit. The third prints the same PID, 3120: there is no new process, your own Bash ran it, and that is why everything persists. The ${MY_VARIABLE:-(does not exist)} stops echo printing an ambiguous blank; you will see it in detail in 04-02.

Solution 2.

#!/usr/bin/env bash
#
# release_info.sh - Reports the active release of Tramontana Bookings.
#
# Author : Systems operator <operator@srv-tramontana>
# Date   : 2026-08-18
# Usage  : release_info.sh  (read-only)

set -euo pipefail

APP_LINK="/opt/tramontana/app"

# readlink -f resolves the link to its final target as an absolute path,
# necessary because 'app' is a *relative* link to the active release.
release_path=$(readlink -f "$APP_LINK")
version=$(basename "$release_path")
size=$(du -sh "$release_path" | cut -f1)

# stat on the LINK (not on its target) gives when it was changed, which is
# exactly when it was deployed. cut removes the fraction of a second.
deployed=$(stat -c '%y' "$APP_LINK" | cut -d'.' -f1)

printf 'Active release : %s\nPath           : %s\n' "$version" "$release_path"
printf 'Size           : %s\nDeployed       : %s\n' "$size" "$deployed"

exit 0
operator@srv-tramontana:~$ chmod 750 ~/scripts/release_info.sh
operator@srv-tramontana:~$ shellcheck ~/scripts/release_info.sh && echo "Clean"
Clean
operator@srv-tramontana:~$ ln -s ~/scripts/release_info.sh ~/bin/release-info
operator@srv-tramontana:~$ release-info
Active release : 3.2.1
Path           : /opt/tramontana/releases/3.2.1
Size           : 98M
Deployed       : 2026-08-11 17:42:03

Solution 3. The four problems, from most to least serious:

  1. An unchecked cd followed by rm -rf *. The lethal flaw: if the directory does not exist or you have no permission, the cd fails, the script carries on wherever you were and deletes everything there. That is SC2164, and it is fixed with cd ... || exit or with strict mode.
  2. #! /bin/sh with [[ ]]. Two errors: the space after #! is accepted by Linux but not by every Unix, and above all /bin/sh is dash, which knows nothing about [[ ]]. The script dies on the last line with [[: not found.
  3. if [[ $? == 0 ]]. Redundant and fragile: in Bash you branch directly on the command (if rm -rf ...; then), as we will see in 04-04. Besides, == compares strings where a numeric test should be used.
  4. Everything else is missing: no header, no strict mode, no explicit exit, and the comment "cleans temp files" does not say what it considers temporary or from when. An rm -rf * deserves more explanation than any other line in the file.

The rewritten version also does what the name promises — delete what is old, not everything:

#!/usr/bin/env bash
#
# clean_temp.sh - Deletes backup temp files older than 7 days.
# Author : Systems operator <operator@srv-tramontana>  Date: 2026-08-18

set -euo pipefail
TEMP_DIR="/srv/tramontana/backups/temp"
RETENTION_DAYS=7

# -mindepth 1 does not touch the directory itself; -xdev does not cross
# mount points; -delete avoids depending on a glob that could be left
# unexpanded and try to delete a file literally called *.
find "$TEMP_DIR" -mindepth 1 -xdev -type f \
    -mtime +"$RETENTION_DAYS" -delete
echo "Temp files older than ${RETENTION_DAYS} days removed."
exit 0

There is no cd any more, so the danger disappears at the root, and strict mode aborts if find fails.

Conclusion

You have the foundations, and there are more of them than it looks.

  • You know when to write a script and when an alias, a function or Ansible is enough, with the three-repetitions rule as your criterion.
  • You understand that a script is plain text with commands, you have mastered the shebang and why #!/usr/bin/env bash is the default choice, and you know the trap that on Ubuntu /bin/sh is dash and [[ ]] does not exist there.
  • You distinguish the three ways of running a script and why only source affects your shell; you put scripts in ~/scripts, publish them with a link in ~/bin and still keep . out of the PATH.
  • You start every script with the same template — shebang, header with usage, set -euo pipefail, constants in uppercase, body and exit 0 — you comment the why, you return exit codes with meaning and you pass shellcheck before considering anything finished.

health_check.sh already exists and already works, but it has its constants nailed into the code, it compares nothing and it cannot say whether the result is good or bad. In the next lesson, Variables and Data Types, we attack the first shortcoming: you will see that in Bash everything is a string and what that implies, you will master parameter expansion — that collection of ${VAR#...}, ${VAR:-...} and ${VAR//.../...} that turns ten lines into one — you will learn why Bash cannot add decimals and what to do with the average amount in bookings.csv, and you will count bookings per house with associative arrays without leaving the shell. By the end of it, health_check.sh will have its configuration outside the body of the script.

Linux Course: From Beginner to System Administrator

Module 1: Introduction to Linux

Module 2: Basic Linux Commands

Module 3: Advanced Command-Line Skills

Module 4: Shell Scripting

Module 5: System Administration

Module 6: Networking and Security

Module 7: Advanced Topics

Module 8: Practical Projects

© Copyright 2026. All rights reserved