Chapter 14Lesson 01~60 minutes

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.

Bash structureQuoting disciplineHands-on script

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.

Direct Bash script execution path
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"]
Invocation changes behavior

./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 1

3. 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.

FormBehaviorTypical purpose
UnquotedExpansions may undergo word splitting and pathname expansion$value
Single quotesPreserve every enclosed character literally'${HOME} *.log'
Double quotesAllow selected expansions while preserving one argument"$HOME/*.log"
BackslashRemoves special meaning from the next character in context\$HOME
workspace="$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' "$@"
Default rule

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?

Question 2. Why is "$@" special?

Question 3. What does ${VALUE:-default} do?

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

Next lesson

Tests, Conditions, case, and Boolean Logic

Use command status and Bash conditional forms to make validation and control flow explicit.

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.