Script Structure, Shebangs, Variables, and Quoting
Build Bash scripts whose interpreter, execution path, data boundaries, and expansion behavior are explicit enough to review, test, and operate safely.
Learning objectives
By the end of this lesson
- Explain how an executable script is selected, interpreted, parsed, expanded, and executed.
- Choose an intentional shebang and distinguish direct execution from
bash script.sh. - Use variables, environment variables,
readonly, and function-local values deliberately. - Predict the effects of unquoted, single-quoted, and double-quoted text.
- Use parameter expansion to require, default, and transform configuration safely.
1. A shell script is a program with an execution contract
A Bash script is not merely a text file containing commands. It has an interpreter contract, input contract, environment, current working directory, permissions, observable output, and exit status. When a script is executed directly, the operating system examines the first line for an interpreter directive, commonly called a shebang. The selected interpreter then reads shell syntax, performs expansions and redirections, executes commands, and returns a status to the caller.
That sequence matters during troubleshooting. An “Exec format error” concerns the executable format or interpreter directive. “Permission denied” may concern mode bits, a no-execute mount, directory traversal, or policy. “Command not found” occurs later, after Bash has started and attempted command lookup. Treating every failure as a Bash syntax problem loses the stage at which the evidence arose.
flowchart TD
I["Invoke ./report.sh"] --> X{"Executable and accessible?"}
X -- no --> E1["Kernel or policy error"]
X -- yes --> S["Read shebang interpreter"]
S --> B["Start Bash with script arguments"]
B --> P["Tokenize and parse shell syntax"]
P --> Q["Perform expansions and redirections"]
Q --> C["Run builtins, functions, or external commands"]
C --> R["Return final exit status"]./script.sh uses the script’s shebang. bash script.sh explicitly selects the current bash command and does not rely on the shebang for interpreter selection.
2. Start with an explicit, inspectable structure
A maintainable script usually identifies its interpreter, explains its purpose, defines stable constants, groups work into functions, and has a visible entry point. The structure is not ceremony: it prevents top-level side effects while a file is sourced, makes functions testable, and gives reviewers a predictable reading order.
#!/usr/bin/env bash
# Produce a small, read-only host report.
# Usage: ./host-report.sh OUTPUT_DIRECTORY
readonly PROGRAM_NAME=${0##*/}
usage() {
printf 'Usage: %s OUTPUT_DIRECTORY\n' "$PROGRAM_NAME" >&2
}
main() {
if (( $# != 1 )); then
usage
return 2
fi
local output_dir=$1
mkdir -p -- "$output_dir"
{
printf 'generated_at=%s\n' "$(date --iso-8601=seconds)"
printf 'hostname=%s\n' "$(hostname)"
printf 'kernel=%s\n' "$(uname -r)"
} > "$output_dir/host-report.txt"
}
main "$@"#!/usr/bin/env bash asks env to find bash through the invoking environment’s PATH. It is convenient for development environments, but it makes the selected interpreter depend on that path. #!/bin/bash names a fixed location, which can be preferable on a controlled Linux fleet. Choose according to the deployment contract; do not copy a shebang without deciding what it promises.
The executable bit is a separate concern:
chmod u+x host-report.sh
./host-report.sh "$HOME/devops-academy/reports"
# These commands answer different questions:
file host-report.sh
head -n 1 host-report.sh
namei -l host-report.sh
command -V bash
/usr/bin/env bash --version | head -n 13. Variables are shell data, not automatically environment data
A shell variable exists inside the current shell. An exported variable is included in the environment passed to child processes. This distinction is fundamental for configuration and secrets. Assignments contain no spaces around =. Names are case-sensitive, and uppercase names are conventionally reserved for environment-style configuration and constants.
project_name='payments-api' # shell variable
export DEPLOY_ENV='staging' # exported to child processes
readonly CONFIG_ROOT='/etc/acme' # cannot be reassigned in this shell
show_context() {
local request_id=$1 # local to this function
printf 'project=%s env=%s request=%s\n' \
"$project_name" "$DEPLOY_ENV" "$request_id"
}
show_context 'req-0042'
env | grep '^DEPLOY_ENV='
# project_name is not printed by env because it was not exported.Use local inside functions to avoid accidental mutation of global state. Use readonly for values that should not change after initialization. Export only values that child processes actually require; every inherited environment value broadens the script’s implicit interface.
4. Quoting controls parsing and expansion boundaries
Shell source passes through several stages. Parameter expansion, command substitution, arithmetic expansion, word splitting, pathname expansion, and quote removal can transform one apparent word into zero, one, or many arguments. Quoting is how you preserve the intended argument boundary.
$value'${HOME} *.log'"$HOME/*.log"\$HOMEworkspace="$HOME/DevOps Academy"
pattern='*.log'
# Correct: one directory argument, even though it contains a space.
mkdir -p -- "$workspace"
# Literal asterisk because the pattern is quoted.
printf 'literal pattern: %s\n' "$pattern"
# Expand matching pathnames intentionally by keeping the glob in source.
for log_file in "$workspace"/*.log; do
[[ -e $log_file ]] || continue
printf 'log: %s\n' "$log_file"
done
# Preserve every original positional argument exactly.
printf 'argument: <%s>\n' "$@"Quote parameter and command substitutions unless you have a specific, reviewed reason to request splitting or glob expansion. Use arrays when you need multiple arguments; do not encode an argument list inside one string.
5. Parameter expansion expresses configuration contracts
Braced parameter expansion can require configuration, provide defaults, assign defaults, inspect length, and remove prefixes or suffixes without launching another process. The colon variants treat an unset or empty value as missing; forms without the colon test only whether the variable is unset.
# Fail immediately with a useful message when required configuration is absent.
: "${SERVICE_NAME:?SERVICE_NAME must be set and non-empty}"
# Use a default without modifying the original variable.
log_level=${LOG_LEVEL:-info}
# Assign a default to the variable itself.
: "${REPORT_DIR:=$HOME/devops-reports}"
artifact='release-2026.08.05.tar.gz'
base=${artifact%.tar.gz}
prefix=${base%%-*}
version=${base#*-}
printf 'service=%s level=%s report_dir=%s\n' \
"$SERVICE_NAME" "$log_level" "$REPORT_DIR"
printf 'prefix=%s version=%s length=%d\n' \
"$prefix" "$version" "${#artifact}"These operators are powerful because they make missing-state behavior visible at the point of use. Avoid long chains of implicit defaults that make production configuration impossible to audit.
6. Hands-on lab: build a quoted environment-report script
Create a script that works correctly when paths and values contain spaces or wildcard characters. The lab writes only beneath your home directory.
lab="$HOME/devops-academy/linux/chapter14/lesson01"
mkdir -p "$lab"
cd "$lab"
cat > environment-report.sh <<'SCRIPT'
#!/usr/bin/env bash
readonly PROGRAM_NAME=${0##*/}
usage() {
printf 'Usage: %s OUTPUT_DIRECTORY\n' "$PROGRAM_NAME" >&2
}
main() {
if (( $# != 1 )); then
usage
return 2
fi
local output_dir=$1
local environment=${DEPLOY_ENV:-development}
local report_file
mkdir -p -- "$output_dir"
report_file="$output_dir/environment report.txt"
{
printf 'generated_at=%s\n' "$(date --iso-8601=seconds)"
printf 'environment=%s\n' "$environment"
printf 'user=%s\n' "$(id -un)"
printf 'shell=%s\n' "${SHELL:-unknown}"
printf 'working_directory=%s\n' "$PWD"
printf 'kernel=%s\n' "$(uname -sr)"
} > "$report_file"
printf 'Wrote %s\n' "$report_file"
}
main "$@"
SCRIPT
chmod u+x environment-report.sh
DEPLOY_ENV='staging blue' ./environment-report.sh "$lab/output directory"
cat "$lab/output directory/environment report.txt"
bash -n environment-report.sh
sha256sum environment-report.sh "$lab/output directory/environment report.txt"Verification checklist
7. Common script-structure mistakes
“The shebang always selects Bash.”
Only direct execution consults it. Running sh script or bash script explicitly selects that interpreter.
“Variables are strings, so quoting is optional.”
Unquoted expansions can become multiple arguments or pathnames. The risk is argument transformation, not merely spaces.
“Export everything for convenience.”
Exported values become an implicit interface inherited by child processes and may expose configuration or secrets.
“A command stored in a string can be executed safely.”
Commands are structured argument vectors. Use functions or arrays rather than constructing shell source and invoking eval.
8. Knowledge check
Question 1. What changes when a script is run as ./task.sh rather than bash task.sh?
bash task.sh explicitly starts the resolved Bash program and passes the file to it.Question 2. Why is "$@" special?
Question 3. What does ${VALUE:-default} do?
default when VALUE is unset or empty without necessarily assigning that default back to VALUE.9. Summary
Reliable Bash begins with an explicit execution contract. Select the interpreter deliberately, separate direct execution from explicit interpreter invocation, define a visible entry point, limit variable scope, export only required configuration, and quote expansions to preserve argument boundaries. Parameter expansion then turns missing and defaulted configuration into reviewable policy instead of accidental behavior.
10. Further reading
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this address.