Script Structure and Execution
Topic 58

Script Structure and Execution

Foundations

A shell script is an ordinary text file. It only becomes a program because of two things the kernel checks at exec time: the file has the executable bit set, and its first line tells the kernel which interpreter to run. There is no compilation and no special extension — deploy.sh and deploy are equally valid names, and the .sh suffix is a human convention the kernel ignores.

Getting the structure wrong is the most common reason a script that runs fine for its author fails on a server. A missing execute bit, the wrong interpreter path, a Windows-edited file with carriage returns, or assuming /bin/sh is bash — each produces a confusing error far from the real cause. The rules below are the same on Debian, Ubuntu, and Red Hat; only the default /bin/sh target differs.

The shebang line and interpreter selection

When you run a file directly, the kernel's execve() reads the first two bytes. If they are 0x23 0x21 — the characters #! — the kernel treats the rest of that line as the interpreter path and runs that interpreter with your script as an argument. #!/bin/bash becomes /bin/bash ./script.sh. The line is parsed by the kernel, not the shell, and the path must be absolute. Linux only reads the first 255 characters of the shebang (BINPRM_BUF_SIZE is 256 bytes since kernel 5.1, up from 128); on current kernels a path that runs past the buffer fails the exec with ENOEXEC rather than being silently truncated as older kernels did.

There are two common forms. #!/bin/bash hardcodes the path, which is reliable on Debian and Ubuntu where bash lives at /bin/bash, but breaks on systems where it sits at /usr/local/bin/bash (FreeBSD, some minimal images). #!/usr/bin/env bash asks env to find bash on PATH, which is portable but cannot pass interpreter flags reliably and trusts whatever bash appears first on the path. If a file has no shebang at all and you run it from an interactive shell, your current shell runs it as a series of commands — which is why a Python file with no shebang sometimes "works" until cron runs it with /bin/sh.

# Hardcoded path: reliable on Debian/Ubuntu, brittle across OSes
#!/bin/bash

# Resolve via PATH: portable, but flags after the interpreter are unreliable
#!/usr/bin/env bash

# Confirm what the kernel will read
head -c2 deploy.sh | xxd
# 00000000: 2321                       #!

Execution methods and what each requires

There are three ways to run a script, and they differ in what permissions they need and which process executes the code. ./script.sh asks the kernel to exec the file: it needs the execute bit and a valid shebang, and it runs in a new child process (a subshell). bash script.sh launches bash explicitly and tells it to read the file: it ignores the shebang entirely and needs only the read bit, not execute. source script.sh (or its POSIX synonym . script.sh) runs the file's commands in your current shell — no subshell, so any cd, variable, or export it sets persists in your session.

You must write ./script.sh rather than script.sh because the shell only searches PATH for bare command names, and . is deliberately not on root's PATH — a security default so a malicious ls dropped in a directory is not run by accident. The leading ./ gives an explicit relative path that bypasses the PATH search. Choosing source matters when a script is meant to change your environment: a script that sets export AWS_PROFILE=prod does nothing useful when run with ./, because the export dies with the subshell.

MethodNeeds +xReads shebangRuns in
./script.shYesYesNew subshell
bash script.shNo (read only)NoNew subshell
source script.shNo (read only)NoCurrent shell

The executable bit and how exec checks it

The execute permission is one bit per class — user, group, and other — shown as the x positions in ls -l. chmod +x script.sh sets it for all three classes; chmod u+x sets it only for the owner. execve() refuses to run a file unless the calling user has execute permission through one of those classes, returning EACCES, which the shell reports as "Permission denied." This is a per-file check, separate from the read bit that bash script.sh relies on.

A filesystem-level override can defeat the bit entirely. If the mount carrying the script was mounted with the noexec option — common for /tmp, /var/tmp, and /home on hardened servers — ./script.sh fails with "Permission denied" even when ls -l shows rwxr-xr-x. The workaround that still functions is bash script.sh, because that path never asks the kernel to exec the file; it only reads it. Check the mount with findmnt -T script.sh before assuming the permissions are wrong.

# Owner-only execute, the least-privilege default
chmod u+x deploy.sh
ls -l deploy.sh
# -rwxr--r-- 1 deploy deploy 412 May 30 09:14 deploy.sh

# EACCES despite the x bit: the mount is noexec
findmnt -T deploy.sh -o TARGET,OPTIONS
# /tmp   rw,nosuid,nodev,noexec

Exit codes and $?

Every process returns an exit code between 0 and 255. By convention 0 means success and any non-zero value means failure; $? holds the code of the last command. This is the contract that && and || depend on: make && make install runs the second command only if the first returned 0, and command || exit 1 bails out the moment a command fails. A script with no explicit exit returns the code of its final command, which is rarely what you intend after a cleanup step.

set -e makes the shell exit immediately when any untested command returns non-zero, turning silent failures into hard stops. Its scope has sharp edges: a command whose result is consumed by if, &&, ||, or ! is considered tested and does not trigger the exit, and by default only the last command in a pipeline sets the status — so grep missing file | sort succeeds even though grep failed. Adding set -o pipefail propagates the first failing command's code through the pipeline, which is why set -euo pipefail is the standard opening for a serious bash script.

# Inspect the exit code of the previous command
systemctl is-active nginx
echo $?
# 0 = active; 3 = inactive

# Chain on success / failure
mkdir -p /opt/app && tar xzf app.tgz -C /opt/app || exit 1

sh versus bash on Debian and Ubuntu

On Debian and Ubuntu, /bin/sh is a symlink to dash, not bash. Dash is a small, strict POSIX shell chosen because it starts faster, which speeds boot-time and package-install scripts. A script with #!/bin/sh therefore runs under dash on these systems, while the same shebang runs under bash on Red Hat and Fedora, where /bin/sh points at bash in POSIX mode. This single difference is the source of an entire class of "works on my machine" bugs.

Bashisms — features dash does not implement — break under dash with errors like "Syntax error: bad substitution." The common offenders are [[ ]] test expressions, arrays, ${var/old/new} substitution, local -n, and the source keyword (dash wants .). If you need those features, declare #!/bin/bash and accept the dependency; if you genuinely need POSIX portability, write to the dash subset and check it with checkbashisms from the devscripts package. Do not write bash and label it #!/bin/sh.

Common Mistakes
  • Forgetting chmod +x and then running ./script.sh, which fails with "Permission denied" even though the file is correct. Either set the bit or run it with bash script.sh.
  • A wrong shebang path such as #!/usr/local/bin/bash on a Debian box where bash is at /bin/bash, producing "bad interpreter: No such file or directory." Confirm the path with command -v bash.
  • CRLF line endings from a Windows editor, which make the shebang #!/bin/bash\r and trigger "bad interpreter: /bin/bash^M." Strip them with dos2unix script.sh or sed -i 's/\r$//'.
  • Assuming /bin/sh is bash on Debian/Ubuntu and using [[ ]] or arrays under #!/bin/sh, which dash rejects with "Syntax error."
  • Not checking exit codes after a command that can fail, so the script marches on after a failed cd or tar and operates on the wrong directory.
  • Relying on set -e inside a pipeline without pipefail, so curl bad-url | tar xz appears to succeed because only tar's exit code is seen.
  • Running an environment-changing script with ./script.sh instead of source, so its export and cd vanish with the subshell and the caller sees no effect.
Best Practices
  • Use #!/usr/bin/env bash when a script may run across distributions and OSes, so it follows PATH instead of a hardcoded location.
  • Start serious bash scripts with set -euo pipefail so unset variables, failed commands, and broken pipelines stop the run instead of corrupting state.
  • Quote every variable expansion as "$var" and "$@" to survive spaces, globs, and empty values; unquoted expansion is the root of most word-splitting bugs.
  • Check exit codes explicitly with if ! command; then or command || handle_error where failure must be acted on, rather than trusting set -e alone.
  • Run shellcheck script.sh in CI; it catches quoting errors, bashisms in sh scripts, and unsafe patterns before they reach a server.
  • Prefer [[ ]] over [ ] in bash for tests, since it avoids word-splitting and supports pattern matching, but stay with [ ] if the shebang is #!/bin/sh.
  • Set ownership and the execute bit deliberately with chmod u+x rather than chmod +x, granting execute only to the account that should run the script.
Comparable toolsPowerShellPython scriptsWindows batch files

Knowledge Check

A script needs to change the caller's working directory and export a variable that persists in the user's shell. How should it be run?

  • ./script.sh, after chmod +x
  • source script.sh, so it runs in the current shell
  • bash script.sh, which preserves the caller's environment
  • env script.sh, which inherits the parent shell

A script with -rwxr-xr-x permissions in /tmp fails with "Permission denied" when run as ./script.sh. What is the most likely cause?

  • The execute bit is missing for the owner
  • /tmp is mounted noexec, so the kernel refuses to exec the file
  • The shebang line points at an interpreter path that does not exist
  • The file has CRLF line endings

Why can a script using [[ ]] and arrays fail on Debian when its shebang is #!/bin/sh?

  • /bin/sh on Debian is simply an older build of bash that lacks those particular features
  • /bin/sh is dash, a POSIX shell that does not implement those bashisms
  • The script needs the execute bit before bash features work
  • Arrays require set -o pipefail to be enabled first

With set -e but not pipefail, why does curl https://bad-url | tar xz let the script continue even though the download failed?

  • set -e ignores any command that produces output
  • A pipeline's status is its last command, so only tar's exit code is checked
  • curl never returns a non-zero exit code when it hits a bad URL, even with set -e active
  • set -e only applies to commands run with sudo

You got correct