Your first script
Shebang, permissions, PATH, and why sh script.sh is not bash.
tar -xzf bo-first.tar.gz, which creates bo-first/. SHA-256: 2ea9ca78a65b0a9a34a15805e001d10dc4218ef4d1172ffef69743648db5b9a7By the end of this lesson you can write a Bash script, make it executable, run it by path or by name, and say exactly what happens when you type ./script, bash script, sh script or source script. Those four look interchangeable and are not. On Ubuntu 26.04 one of them runs a different shell altogether, and another runs the file inside the shell you are typing in. Knowing which is which is the difference between a script that works at your prompt and one that quietly fails in a scheduled job. The course assumes you are at home at a Linux prompt (files, permissions, pipes) and teaches the scripting part.
Write the file and hand it to bash
A shell is the program that reads the commands you type and runs them. Your login shell on Ubuntu is Bash. A script is a plain text file of those same commands, and the shell reads it from top to bottom, one command after another. Make a directory for this lesson and go into it (mkdir -p ~/bash-ops/first && cd ~/bash-ops/first), then save this file there as hello.sh with any editor (nano hello.sh is fine):
#!/usr/bin/env bash# hello.sh: my first script. Prints a greeting, then the machine's name.echo "Hello from my first script"hostname
The first line is the shebang, which the section after next explains. The second line starts with #, which makes it a comment: Bash ignores everything from # to the end of the line. Use comments to say why a script exists or why a line is there, not to repeat what the command obviously does. The last two lines are ordinary commands: echo prints its arguments, and hostname prints the machine's name.
bash hello.sh starts a new Bash process and tells it to read the file. That works for any file you can read: it needs no special permission and ignores the first line. You can see the two commands ran in order: the greeting, then web01, the name of the lab machine.
Make it a program: the execute bit, ./ and PATH
The tools you use every day run by name. You type ls, not bash /usr/bin/ls. For your script to behave the same way, two things must be true: the file must be marked as a program, and the shell must be able to find it. From here on the terminals show several commands at once, so first a word on how to read them.
$ is a separate command, typed at the prompt and run with Enter, one after another. The terminal shows all of their output together after the last command. The terminals were recorded on a lab machine called web01 as the user deploy, so prompts and paths say deploy and /home/deploy; yours show your own. The lab ran each terminal in a fresh shell that was not interactive, and three things look different at your prompt. Error messages from Bash start with -bash: line N: in the lab (N is a line number) and have no line N: part at a prompt. A command started in the background with & makes your prompt print a job line such as [1] 12345, and later [1]+ Done or [1]+ Terminated when it ends; the lab shows neither. And a command that changes the shell itself (cd, source, shopt, export) changes your shell for the rest of the session, so the lessons undo such changes where later steps depend on it. Open a new terminal for each lesson, work in the lesson's directory, and create every file a lesson lists before you run the terminals that use it.Now try running the script by path before changing anything:
The system refused to start the file because nothing marks it as runnable. The second line prints $?, a special variable that holds the exit status of the last command: a number from 0 to 255 where 0 means success and anything else means some kind of failure. When Bash finds a file but may not execute it, the status is 126. The lesson "Exit status and tests" covers the common numbers; for now, read 0 as "worked" and anything else as "did not".
chmod +x (change mode, add execute) turned -rw-rw-r-- into -rwxrwxr-x: the owner, the group and everyone else may now run the file. The group also has write access because Ubuntu gives every user a private group of the same name, and new files for normal users start group-writable. When a script refuses to run, add execute permission and nothing else. chmod 777 also lets every account on the machine rewrite the script, and the next time you run it you run their version.
The ./ in front means "the file named hello.sh in this directory": . is the current directory and / separates it from the name. The exit status is 0. A script's exit status is the status of the last command it ran (here hostname), or the number you give to exit if the script ends with one. Now try the bare name:
A name without a slash is not looked up in the current directory. Bash searches the directories listed in the PATH variable, left to right, and runs the first executable file with that name. When no directory has it, you get command not found and exit status 127.
Your current directory is not on that list, on purpose. If it were, anyone who could drop a file named ls into a shared directory such as /tmp would run code as you the next time you typed ls there. Typing ./ costs two characters and removes that risk.
For tools you write for yourself, Ubuntu already has a place: ~/.local/bin. The default ~/.profile adds it to PATH when you log in, but only if the directory exists at that moment. Create it and copy the script in under a plain name:
It fails because this shell worked out its PATH when you logged in, before the directory existed. Log out and back in (or open a new SSH session), and the name works:
command -v hello prints the file Bash would run for that name. Use it whenever a name runs something you did not expect. A file that is on PATH but lacks the execute bit is reported as Permission denied with status 126, so for a bare name "command not found" means that no directory in PATH has a file of that name.
The shebang line, and what sh does with it
When you run a file by path, the kernel reads its first two bytes. If they are #! (the shebang), the rest of the line names the interpreter, the program that should read the file. #!/usr/bin/env bash asks the small program env to find bash on your PATH and start it with the script as its argument. This script reports which program is reading it, then uses a Bash test:
#!/usr/bin/env bash# which-shell.sh: show which program is reading this file, then use a Bash-only test.echo "read by: $(readlink /proc/$$/exe)"if [[ -d /var/log ]]; thenecho "the [[ ]] test ran"fiecho "reached the last line"
Line 3 prints the executable behind the process that runs the script: $$ is that process's ID, /proc/$$/exe is a link to its program file, and $( ) pastes a command's output into the line (the next lesson covers it). Line 4 uses [[ ]], Bash's test command, to check that /var/log is a directory. Run the file three ways:
The first two ran Bash. The third ran /usr/bin/dash. On Ubuntu, sh is Dash, a small, fast shell that implements the POSIX shell language (the portable standard every Unix sh follows) and little more. When you type sh which-shell.sh you pick the interpreter yourself, so the shebang is just a comment. Dash has no [[, so it tried to run a command named [[, failed with which-shell.sh: 4: [[: not found (file, line number, message), and carried on. The if treated the failure as "false" and skipped its body, the last echo succeeded, and the script exited 0. A script that prints an error and still reports success is exactly what an automated job cannot notice. Later lessons make scripts stop on errors; the first step is to run them with the right shell.
Why env? #!/usr/bin/env bash finds whichever bash comes first on the caller's PATH, so the same file works where Bash is not /bin/bash (macOS with a newer Bash from Homebrew, for example). The cost is that the interpreter now depends on the caller's PATH, and that Linux passes everything after the interpreter path as one argument: a first line of #!/usr/bin/env bash -e makes env look for a program literally named bash -e and fail with env: 'bash -e': No such file or directory. For a script that only ever runs on your Linux servers, often as root, #!/bin/bash pins the interpreter and is a good choice. This course uses the env form. Either way the shebang must be the very first line of the file, with nothing before #!.
Run it or source it
Every way of running a script you have seen so far starts a child process: a new program with its own copy of the shell's variables and its own current directory. When it ends, whatever it changed inside itself ends with it. source (its POSIX spelling is a single dot, .) is different: your current shell reads the file and runs its lines itself, as if you had typed them. This script changes directory and sets a variable:
#!/usr/bin/env bash# go-logs.sh: change to the log directory and report where the script is.log_dir=/var/logcd "$log_dir" || exit 1echo "inside the script: $PWD"
log_dir=/var/log stores a value in a variable, and "$log_dir" reads it back (the next lesson is about variables). cd "$log_dir" || exit 1 changes directory and stops the script with status 1 if that fails; || means "run the right side only if the left side failed". Run it, then source it, and look at the prompt's directory and variable afterwards:
Run as ./go-logs.sh, the script moved itself to /var/log, but your shell never moved and never saw log_dir. Sourced, the same lines ran in your shell, so your shell really was in /var/log afterwards and log_dir was set. The last line, cd -, goes back to the directory you were in before the last cd and prints it; type it too, or the next commands run in /var/log. Use source for files that exist to change your shell, such as a file of settings or functions you want in your session. Do not source programs. An exit in a sourced file ends your own shell (at a terminal, the window or SSH session closes), and a sourced file runs with all your variables and permissions, so source only files you trust as much as your own.
Bash or POSIX sh: know which one you wrote
POSIX defines a portable sh language. Dash implements it closely; Bash implements it plus many extensions. Those extensions are what make Bash pleasant to write, and they are exactly what breaks when a Bash script is run by sh:
Line 1 builds an array (a list of values in one variable, a Bash feature), and Dash rejects the syntax outright with exit status 2. Bash, in line 2, prints the second element, web02. Dash has no source command, but it runs the same file with ., which is why scripts meant for both shells use the dot. The table below lists the Bash-only features this course uses, each checked under Dash 0.5.12 on the lab machine; later lessons mark them as Bash-only when they appear.
The rule for this course: every script starts with #!/usr/bin/env bash, and you run it by path (./backup.sh, /opt/ops/backup.sh, or by name from ~/.local/bin) or with bash, never with sh. Watch for the places that choose sh for you. Cron runs every crontab line with /bin/sh unless the crontab sets SHELL, so keep the Bash inside a script with a shebang and have the crontab line call the script by its path. A file whose first line is #!/bin/sh is a POSIX script, and adding Bash features to it breaks it the same way.
Try this
In ~/bash-ops/first, write uptime-report.sh: a shebang line, the command hostname, and a block that runs uptime -p only when [[ -r /proc/uptime ]] is true (copy the if ... then ... fi shape from which-shell.sh). Then check each of these, and predict the result before you press Enter:
1. Before any chmod, run ./uptime-report.sh and then echo "exit status: $?". Expect Permission denied and 126.
2. Run chmod +x uptime-report.sh and try again. Expect the host name, a line such as up 19 minutes, and 0.
3. Run sh uptime-report.sh, then echo "exit status: $?". Expect uptime-report.sh: N: [[: not found, where N is the line your if is on (4 in the lab's copy, which has a comment on line 2), and still 0, and explain why from what you saw with which-shell.sh.
4. Copy it to ~/.local/bin/uptime-report, log in again, run uptime-report by name, and confirm with command -v uptime-report which file ran.
Takeaway
Start every Bash script with #!/usr/bin/env bash, make it executable, and run it by path or by name from ~/.local/bin. sh script runs a different shell and source script runs the file inside yours, so type either one only when that is what you mean.
sh /opt/ops/rotate.sh. The script starts with #!/usr/bin/env bash. The job log shows rotate.sh: 12: [[: not found, yet the job is reported as successful. What happened?sh rotate.sh the shebang is never read at all.sh file only needs read permission, and a missing execute bit never switches interpreters: ./file fails with Permission denied (126).sh chooses Dash and turns the shebang into a comment. Dash reports [[: not found in its "file: line:" format, the if treats that as false, and the script's status is its last command's.[[ ]]; the lesson ran it successfully with ./ and with bash. The "file: 12:" format of the message is Dash's, not Bash's../set-app.sh contains cd /srv/app and app_home=/srv/app. After it runs, your prompt is still in your home directory and echo "$app_home" prints an empty line. What explains this?bash or with sh starts a child process. source set-app.sh (or . set-app.sh) runs the lines in your current shell instead.export copies a variable to the children a shell starts, never back to the parent. Your shell started the script, so nothing the script exports reaches you.bash set-app.sh also starts a child process, so the result is the same. cd does work inside the script: the lesson's go-logs.sh printed /var/log from inside itself.mkdir -p ~/.local/bin, copy your executable tool there as diskcheck, and in the same session typing diskcheck prints "command not found" (status 127). Why?hash -r cannot help, because the directory is not in PATH at all.echo "$PATH" in this session shows no ~/.local/bin. Log in again (or open a new SSH session) and the name resolves; command -v diskcheck then shows the path.