Your First Bash Script: Shebang, Shell Types and Limits

You’ve typed commands in a terminal for a while, maybe you’ve read what shell scripting is and how shells differ, and now you want to write a script that does real work. Good. This post covers the four things that trip people up at exactly that point: when a shell script is the right tool at all, how to write and run a first script that won’t embarrass you later, what the shebang line really does, and why the same script behaves differently in your terminal, over SSH and in cron.

Everything below ran on Ubuntu 26.04 and RHEL 9, and the output is copied straight from those sessions. Including the failures, which are the useful part.

Shell scripting: advantages, limitations, and when to use another language

Where Bash shines

  • It’s already installed. Every Linux server has a shell. No runtime, no packages, no virtual environment. On a half-broken box, that matters.
  • It glues commands together. If the job is “run these five tools in order and react to their exit codes,” nothing is shorter than a shell script.
  • It’s what automation runs anyway. Cron jobs, Dockerfile RUN lines, CI steps, systemd hooks, cloud-init. All shell.
  • Quick to write, quick to read, for anything that’s mostly commands.

Where it struggles

Bash is an interpreter for commands, not a general-purpose language, and it shows the moment you ask it to do real computation. Here’s the same job, adding up a million numbers, done three ways on the same Ubuntu VM:

time bash -c "s=0; for ((i=0;i<1000000;i++)); do ((s+=i)); done; echo $s"
499999500000
real 3.593s

time awk "BEGIN{for(i=0;i<1000000;i++)s+=i; print s}"
499999500000
real 0.081s

time python3 -c "print(sum(range(1000000)))"
499999500000
real 0.044s

Bash took 3.6 seconds; awk did the same loop in 0.08. (To be fair to Bash, the Python line uses the built-in sum() rather than a hand-written loop, but that’s rather the point: other languages give you tools Bash doesn’t have.) Speed is only one limit. The others hurt more often:

  • Quoting. An unquoted variable with a space in it silently becomes two arguments. You’ll see this blow up for real in the next section.
  • Error handling. By default a failed command doesn’t stop the script, so a script happily reports success after doing nothing.
  • Data structures. There are arrays and associative arrays, but no nested data. Parsing JSON or YAML in pure Bash is misery.
  • Portability. Bash features don’t exist in sh, and sh is a different program on Ubuntu than on RHEL.

So when should you switch?

A rough rule that has served me well: stay in Bash while the script is mostly running commands and checking their results. Move to Python (or Go) when any of these show up:

  • You’re parsing JSON, YAML or API responses beyond a quick jq one-liner.
  • You need real data structures, or the logic is more decisions than commands.
  • The script passes a couple of hundred lines, or other people will maintain it.
  • It loops over large amounts of data, where the speed gap above starts to matter.

And for keeping many servers in a known state, use configuration management like Ansible instead of a loop of SSH commands.

How to write and run your first Bash script

Hello-world scripts teach the mechanics, but not the habits. So let’s write something you’d actually keep: a backup script that archives a directory with a timestamp and keeps only the newest three copies.

Step 1: Write it

#!/bin/bash
# backup.sh - archive a directory with a timestamp and keep the newest 3 copies
set -euo pipefail

src="${1:?usage: backup.sh <directory>}"
dest="$HOME/backups"
keep=3

mkdir -p "$dest"
name="$(basename "$src")-$(date +%Y%m%d-%H%M%S).tar.gz"

tar -czf "$dest/$name" -C "$(dirname "$src")" "$(basename "$src")"
echo "Created $dest/$name ($(du -h "$dest/$name" | cut -f1))"

# delete everything older than the newest $keep archives
ls -1t "$dest/$(basename "$src")"-*.tar.gz | tail -n +$((keep + 1)) | while read -r old; do
  rm -- "$old"
  echo "Removed old backup: $old"
done

The lines worth understanding:

  • #!/bin/bash, the shebang. It tells the kernel which program runs the file. There’s a whole section on it below.
  • set -euo pipefail makes the script stop on the first failed command (-e), on an unset variable (-u), and on a failure anywhere in a pipe (pipefail). Put it in every script.
  • ${1:?usage: ...} takes the first argument, and if it’s missing, prints the message and exits. One line of input validation.
  • Every variable is in double quotes. "$src", never bare $src. This one habit prevents most Bash bugs.

Step 2: Check it, make it executable, and run it

bash -n parses the script without running anything, which catches syntax errors safely. Then give the file execute permission:

bash -n backup.sh && echo syntax OK
syntax OK
chmod +x backup.sh

Run it without an argument first, to see the guard work:

./backup.sh
./backup.sh: line 5: 1: usage: backup.sh <directory>
echo "exit code: $?"
exit code: 1

Now for real, on a directory with a space in its name, on purpose. Run it four times:

./backup.sh "my notes"
Created ~/backups/my notes-20260114-225658.tar.gz (8.0K)
./backup.sh "my notes"
Created ~/backups/my notes-20260114-225659.tar.gz (8.0K)
./backup.sh "my notes"
Created ~/backups/my notes-20260114-225700.tar.gz (8.0K)
./backup.sh "my notes"
Created ~/backups/my notes-20260114-225702.tar.gz (8.0K)
Removed old backup: ~/backups/my notes-20260114-225658.tar.gz

ls ~/backups
my notes-20260114-225659.tar.gz
my notes-20260114-225700.tar.gz
my notes-20260114-225702.tar.gz

Three kept, the oldest rotated out, and the space in the name never caused a problem. The ./ is needed because the shell only searches directories in $PATH for commands, and your current directory isn’t one of them.

Step 3: See what skipping the quotes costs you

Here’s the same idea written the way many first scripts are:

cat buggy.sh
#!/bin/bash
src=$1
tar -czf ~/backups/$src.tar.gz $src
echo "Backed up $src"

bash buggy.sh "my notes"
tar: notes.tar.gz: Cannot stat: No such file or directory
tar: my: Cannot stat: No such file or directory
tar: notes: Cannot stat: No such file or directory
tar: Exiting with failure status due to previous errors
Backed up my notes

The unquoted $src split “my notes” into two words, tar got the wrong arguments and failed, and then the script printed “Backed up my notes” anyway because nothing told it to stop. A backup script that lies about backing up. That’s why quotes and set -e aren’t optional.

Step 4: Let ShellCheck review it

ShellCheck is a linter for shell scripts and catches exactly this kind of bug. Install it with sudo apt install shellcheck (sudo dnf install ShellCheck from EPEL on RHEL):

shellcheck buggy.sh

In buggy.sh line 3:
tar -czf ~/backups/$src.tar.gz $src
                   ^--^ SC2086 (info): Double quote to prevent globbing and word splitting.
                               ^--^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
tar -czf ~/backups/"$src".tar.gz "$src"

It even suggests the fix. In fairness, it had a note for my version too:

shellcheck backup.sh

In backup.sh line 16:
ls -1t "$dest/$(basename "$src")"-*.tar.gz | tail -n +$((keep + 1)) | while read -r old; do
^-- SC2012 (info): Use find instead of ls to better handle non-alphanumeric filenames.

That’s an info-level hint, not a bug: parsing ls breaks on file names containing newlines. Here the script creates the names itself, so it’s safe, but it’s exactly the kind of thing worth knowing. Run ShellCheck on everything you write.

Step 5: Run it from anywhere

Copy it into ~/.local/bin without the .sh and it works like any other command. On Ubuntu, ~/.profile adds that directory to PATH automatically:

mkdir -p ~/.local/bin && cp backup.sh ~/.local/bin/backup

grep -n -A2 "local/bin" ~/.profile
25:if [ -d "$HOME/.local/bin" ] ; then
26:    PATH="$HOME/.local/bin:$PATH"
27-fi

Notice that the addition lives in ~/.profile, which only login shells read. Keep that in mind, because it’s about to matter.

Bash shebang explained: #!/bin/bash vs #!/usr/bin/env bash

When you run ./backup.sh, the kernel reads the first two bytes. If they’re #!, it runs the program named on that line and hands it your script. That’s the whole mechanism. The common question is which path to use.

  • #!/bin/bash runs exactly that file.
  • #!/usr/bin/env bash runs env, which searches your $PATH and runs the first bash it finds.

Here’s a script that prints which Bash ran it, in both versions:

cat env.sh
#!/usr/bin/env bash
echo "running under: $BASH (version ${BASH_VERSION%%(*})"
[[ $1 == web* ]] && echo "$1 matched"

./abs.sh web01
running under: /bin/bash (version 5.3.9)
web01 matched
./env.sh web01
running under: /usr/bin/bash (version 5.3.9)
web01 matched

Same Bash either way, because on modern Ubuntu and RHEL /bin is just a link to /usr/bin:

ls -ld /bin
lrwxrwxrwx 1 root root 7 Apr 20 13:46 /bin -> usr/bin

Why env can surprise you

The flexibility of env is also its weakness: it trusts PATH. Put a directory in front of PATH containing something named bash (here, a link to the much simpler dash shell) and watch what happens:

ls -l ~/fakebin/bash
lrwxrwxrwx 1 asif asif 13 Jan 14 22:56 ~/fakebin/bash -> /usr/bin/dash

PATH=~/fakebin:$PATH ./abs.sh web01
running under: /bin/bash (version 5.3.9)
web01 matched

PATH=~/fakebin:$PATH ./env.sh web01
running under:  (version )
./env.sh: 3: [[: not found

The #!/bin/bash script didn’t care. The env one ran under dash and broke. In real life the “other bash” is usually an older or newer Bash from Homebrew, Conda or a custom build, but the point stands: env runs whatever PATH says.

The second catch: on Linux, env treats everything after it as a single program name, so passing flags fails:

head -1 flags.sh
#!/usr/bin/env bash -e
./flags.sh
env: 'bash -e': No such file or directory
env: use -[v]S to pass options in shebang lines

The error even tells you the fix. env -S splits the line properly:

head -1 flags2.sh
#!/usr/bin/env -S bash -e
./flags2.sh
env -S worked, flags: ehB

Which should you use? #!/bin/bash for scripts that run as root, from cron or systemd, or on servers: predictable, and not affected by whatever PATH a user has. #!/usr/bin/env bash for scripts you share across machines where Bash may live elsewhere, like macOS with a Homebrew Bash, or the BSDs. And use set -euo pipefail inside the script instead of flags on the shebang line, which sidesteps the env problem entirely.

Two shebang gotchas worth knowing

A script written on Windows has CRLF line endings, and the hidden carriage return becomes part of the interpreter name:

./crlf.sh
bash: ./crlf.sh: /bin/bash^M: bad interpreter: No such file or directory
file crlf.sh
crlf.sh: Bourne-Again shell script, ASCII text executable, with CRLF line terminators
sed -i "s/r$//" crlf.sh && ./crlf.sh
hello

That ^M is the giveaway. And second, the shebang only applies when you run the file directly. Naming an interpreter yourself overrides it:

sh env.sh web01
running under:  (version )
env.sh: 3: [[: not found
bash env.sh web01
running under: /usr/bin/bash (version 5.3.9)
web01 matched

sh script.sh on Ubuntu means dash, whatever the first line says. If a script “works on my machine” but not for a colleague, check how they’re launching it.

Interactive, non-interactive, login and non-login shells explained

Every Bash process is two things at once: interactive or not (is there a prompt and a person?) and login or not (is this the first shell of a session?). The combination decides which startup files Bash reads, and that explains a whole class of “but it works in my terminal” bugs.

To see it rather than take my word for it, I created a test user on Ubuntu, put a marker line at the top of its ~/.profile and ~/.bashrc (plus one more after .bashrc’s interactive check), then started every kind of shell. Each one prints $0, the shell’s flags in $- (an i means interactive), and Bash’s own login_shell setting:

##### 1) ssh shelldemo@host  (interactive login shell)
>> ~/.profile read
>> ~/.bashrc read (top)
>> ~/.bashrc read (after the interactive check)
[$0=-bash] [$-=himBHs] [login_shell=on]

##### 2) typing "bash" inside it  (interactive non-login)
>> ~/.bashrc read (top)
>> ~/.bashrc read (after the interactive check)
[$0=bash] [$-=himBHs] [login_shell=off]

##### 3) bash -c  (non-interactive, non-login: how scripts run)
[$0=bash] [$-=hBc] [login_shell=off]

##### 4) bash -l -c  (non-interactive login)
>> ~/.profile read
>> ~/.bashrc read (top)
[$0=bash] [$-=hBc] [login_shell=on]

##### 5) ssh shelldemo@host 'command'  (non-interactive, started by sshd)
>> ~/.bashrc read (top)
[$0=bash] [$-=hBc] [login_shell=off]

Put together:

How the shell started Interactive? Login? Files read (Ubuntu)
SSH login, console login, su - Yes Yes ~/.profile, which sources ~/.bashrc
New terminal tab, typing bash Yes No ~/.bashrc
Running a script, bash -c No No None
bash -l -c No Yes ~/.profile (and .bashrc’s top only)
ssh host 'command' No No ~/.bashrc’s top only

A few things in that output deserve a closer look.

The dash in -bash is how a login shell identifies itself. echo $0 is the fastest way to tell which kind you’re in.

Scripts read nothing. Case 3 printed no markers at all. A script doesn’t read .bashrc, so your aliases and functions don’t exist inside it, and it only sees the environment it inherited.

ssh host 'command' is the odd one out. It’s neither interactive nor login, yet Bash still read ~/.bashrc, because Bash makes a special exception when it’s started by sshd. The only thing that stopped the rest of the file running was this guard Ubuntu puts at the top:

# If not running interactively, don't do anything
case $- in
    *i*) ;;
      *) return;;
esac

Anything you add above that guard runs on every ssh host command, including from automation tools. Anything that prints output there can break scp and rsync. Keep additions below it.

Ubuntu vs RHEL startup files

The two families ship different files in /etc/skel, the template for new users:

# Ubuntu 26.04
ls -a /etc/skel
. .. .bash_logout .bashrc .kshrc .profile

# RHEL 9
ls -a /etc/skel
. .. .bash_logout .bash_profile .bashrc .mozilla

cat /etc/skel/.bash_profile
# .bash_profile

# Get the aliases and functions
if [ -f ~/.bashrc ]; then
	. ~/.bashrc
fi

Same idea, different file name: the login file sources ~/.bashrc, so interactive settings apply in both kinds of interactive shell. One trap: if a ~/.bash_profile exists, Bash reads it instead of ~/.profile. Create one on Ubuntu without sourcing .profile and you lose the ~/.local/bin line from earlier.

Why your script works in the terminal but not in cron

Remember the backup script installed into ~/.local/bin, and that only login shells add that directory to PATH? Cron starts neither kind of shell. I scheduled a one-line job to report its environment:

cat /tmp/cronenv.txt   # written by cron
SHELL=/bin/sh PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
backup: command not found

echo $PATH   # asif's login shell
/home/asif/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:/snap/bin

Cron runs jobs with /bin/sh and its own PATH, without ~/.local/bin, so backup doesn’t exist there. The fixes: use full paths in crontab lines (/home/asif/.local/bin/backup), set PATH= at the top of the crontab, or set what the script needs inside the script itself. Our cron jobs guide covers crontab syntax in detail.

A practical rule of thumb: environment variables like PATH go in the login file (~/.profile or ~/.bash_profile), interactive niceties like aliases and the prompt go in ~/.bashrc below the guard, and scripts should never depend on either.

Frequently asked questions

Do Bash scripts need the .sh extension?

No. Linux decides how to run a file from its shebang, not its name. .sh helps editors with syntax highlighting; many people drop it for scripts installed into PATH, like our backup command.

What’s the difference between ./script.sh, bash script.sh and source script.sh?

./script.sh uses the shebang and needs execute permission. bash script.sh ignores the shebang and doesn’t need execute permission. Both run in a new process. source script.sh (or . script.sh) runs it inside your current shell, so variables and cd changes stay behind afterwards. That’s how .profile loads .bashrc.

How do I check whether I’m in a login or interactive shell?

echo $0 shows -bash for a login shell. shopt login_shell says on or off. echo $- contains an i when the shell is interactive.

Should I use #!/bin/sh for portability?

Only if you write strict POSIX shell, with no [[ ]], arrays or other Bash features. /bin/sh is dash on Ubuntu and Debian but Bash on RHEL, so a Bash-flavoured script with #!/bin/sh works on one and breaks on the other. If you use Bash features, say bash in the shebang.

How do I debug a script that misbehaves?

Run it with bash -x script.sh to print every command before it runs, with variables expanded. Add set -x and set +x around just the suspicious section for a quieter trace. Pair it with ShellCheck and most bugs are found in minutes.

Asif Khan, author of LinuxPathfinder.com

Asif Khan

DevOps, Linux, and Cloud engineer with 10+ years building infrastructure that stays up, and automation so I do not have to fix it at 2am. My focus is automation, security, and systems built to survive failure, using Infrastructure as Code, CI/CD, containers, monitoring, and cloud platforms instead of manual fixes. This blog is where I write down what worked, and just as often what didn't the first time around. Real tutorials you can run yourself, troubleshooting notes pulled from actual outages, and the kind of detail you only get once you've hit the problem firsthand. If you're trying to get Linux, DevOps, and cloud infrastructure to behave, that's what you'll find here: reproducible solutions you can understand, test, and apply in your own environment.