Script Structure and Execution
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.
| Method | Needs +x | Reads shebang | Runs in |
|---|---|---|---|
| ./script.sh | Yes | Yes | New subshell |
| bash script.sh | No (read only) | No | New subshell |
| source script.sh | No (read only) | No | Current 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.
- 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.
- 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.
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