Linux System Administration and Shell Scripting

Chapter 9
Shell Scripting Fundamentals

Post Graduate Program in Dev Ops
Linux System Administration and Shell Scripting   •   Post Graduate

9. Shell Scripting Fundamentals

Introduction and Context

Linux administration is rarely performed through isolated commands. Administrators combine commands to inspect systems, transform data, validate conditions, automate repetitive work, and respond consistently to operational events. A shell script is a text file containing commands and control structures that the shell interprets. It turns an interactive procedure into a repeatable, reviewable, and automatable program.

Shell scripting is particularly important in DevOps because it connects people, operating-system facilities, configuration tools, build systems, monitoring platforms, and deployment pipelines. A script may create a user, verify disk capacity, rotate logs, test whether a service is running, or prepare a host before application deployment. However, a script is not automatically reliable merely because it is short. Portability, quoting, error handling, security, observability, and maintainability determine whether it is suitable for professional use.

This chapter focuses on Bash and POSIX shell fundamentals. POSIX shell syntax provides a portable baseline across many Unix-like systems, while Bash offers useful extensions such as indexed and associative arrays, shell options, and richer conditional expressions. A sound administrator knows when portability is essential and when a Bash-specific feature is justified.

Learning Objectives

After studying this chapter, the learner should be able to:

1. Explain the role of the shell and the structure of an executable script.

2. Distinguish POSIX shell features from Bash-specific facilities.

3. Use variables, quoting, parameter expansion, command substitution, and arithmetic expansion safely.

4. Apply tests, conditionals, loops, functions, arrays, input/output, and exit-status conventions.

5. Design scripts that are readable, portable, secure, testable, and appropriate for automation.

6. Diagnose common scripting errors and evaluate scripting decisions in professional scenarios.

From Interactive Commands to Programs

A shell reads input, performs parsing and expansion, searches for commands, and launches processes. A command may be a shell builtin, a function, an executable file, or an external utility. The shell also connects commands through pipelines and redirects their standard streams.

A minimal script is:

```sh

#!/bin/sh

printf '%s\n' "System check started"

```

The first line is a shebang. It identifies the interpreter used when the script is executed directly. `#!/bin/sh` is appropriate for POSIX shell code. `#!/usr/bin/env bash` commonly selects Bash through the user's `PATH`, although an organization may prefer a fixed interpreter path for reproducibility.

A script can be run in two different ways:

```sh

sh check.sh

./check.sh

```

The first command explicitly asks `sh` to interpret the file and does not require execute permission. The second requires execute permission, for example `chmod 755 check.sh`, and uses the interpreter named in the shebang. A Bash script should not be run with `sh` merely because both are shells; Bash syntax may fail under a more limited POSIX shell.

Shell scripts should begin with a clear purpose and, in production repositories, normally include ownership, assumptions, and usage information. Comments should explain intent and non-obvious decisions rather than restating syntax.

Variables, Scope, and Expansion

Shell variables are untyped character strings by default:

```sh

name='backup server'

count=3

printf 'Name: %s; count: %s\n' "$name" "$count"

```

There must be no spaces around the assignment operator. A variable is normally local to the current shell process. An assignment affects a child process only when exported:

```sh

environment='test'

export environment

```

Exporting variables creates part of the environment inherited by child commands. It should be used deliberately, because environment variables can influence programs and may expose sensitive values. A variable supplied by a caller is commonly referenced as `$1`, `$2`, and so forth for positional parameters, while `$0` represents the script name and `$#` represents the number of positional parameters.

Special parameters provide operational information. `$?` is the exit status of the most recently executed command, `$$` is the current shell process ID, and `$@` represents positional arguments. In almost all argument-forwarding situations, `"$@"` should be used rather than `$*`, because the quoted form preserves each original argument as a separate word.

Expansion is the process by which shell text becomes arguments. Important forms include:

  • Parameter expansion: `${name}` or `${name:-default}`.
  • Command substitution: `$(command)`.
  • Arithmetic expansion: `$((expression))`.
  • Tilde expansion: `~` for a home directory in suitable contexts.
  • Filename expansion: wildcard patterns such as `*.log`.

For example:

```sh

current_user=$(id -un)

log_dir=${LOG_DIR:-/var/log}

files_found=$((files_found + 1))

```

Command substitution removes trailing newlines from command output and should not be used when exact line preservation is required. A variable can be checked without confusing an unset value with an empty value by using parameter-expansion operators carefully. `${value:-fallback}` uses the fallback when `value` is unset or empty, whereas `${value-fallback}` uses it only when unset.

Bash supports additional parameter operations, such as `${var^^}` for uppercasing and arrays. Scripts requiring these features must explicitly target Bash rather than claim POSIX portability.

Quoting: The Main Boundary of Safety

Quoting controls how the shell interprets spaces, wildcard characters, command substitutions, and other metacharacters. Double quotes preserve the value of a variable as one word while still permitting parameter and command expansion:

```sh

printf '%s\n' "$filename"

```

Single quotes preserve literal text completely:

```sh

pattern='*.log'

```

Unquoted expansion is dangerous when values may contain spaces, tabs, newlines, wildcard characters, or shell metacharacters. Consider:

```sh

rm -- $file

```

If `file` contains several words or expands to a wildcard, the command may remove unintended targets. The safer form is:

```sh

rm -- "$file"

```

The `--` convention tells many utilities that subsequent arguments are operands rather than options. It is especially valuable when a filename begins with a hyphen. Quoting cannot make an unsafe command semantically safe, but it prevents a large class of word-splitting and filename-expansion errors.

A common exception is deliberate iteration over pathname results. Even then, older patterns such as `for file in $(find ...)` are unreliable because command substitution splits on whitespace and loses filename boundaries. Prefer a utility's null-delimited interface or a controlled `find` expression, for example:

```sh

find "$log_dir" -type f -name '*.log' -print0 |

while IFS= read -r -d '' file; do

printf '%s\n' "$file"

done

```

The `-print0` and `-d ''` options are Bash and common GNU-oriented techniques, not universal POSIX facilities. Portable designs must account for the target environment.

Tests and Conditional Decisions

A test produces an exit status rather than a Boolean value printed to the screen. The `test` command and its bracket notation are equivalent in basic cases:

```sh

if [ -f "$config" ]; then

printf 'Configuration exists\n'

fi

```

The closing `]` is an argument and therefore must be separated by spaces. Common tests include `-e` for existence, `-f` for a regular file, `-d` for a directory, `-r` for readability, `-n` for a non-empty string, and `-z` for an empty string. Numeric comparisons use `-eq`, `-ne`, `-lt`, `-le`, `-gt`, and `-ge`, while string equality commonly uses `=` in POSIX test syntax.

A conditional can include alternatives:

```sh

if [ "$status" -eq 0 ]; then

printf 'Healthy\n'

elif [ "$status" -eq 1 ]; then

printf 'Warning\n'

else

printf 'Failure\n' >&2

fi

```

The `case` construct is often clearer than a long chain of string comparisons:

```sh

case ${1:-} in

start) start_service ;;

stop) stop_service ;;

status) show_status ;;

*) printf 'Usage: %s {start|stop|status}\n' "$0" >&2; exit 2 ;;

esac

```

Bash provides `[[ ... ]]`, which avoids some word-splitting behavior and supports pattern matching, but it is not POSIX syntax. Use it when the script explicitly requires Bash.

Exit Status and Error Conventions

By convention, zero means success and a non-zero status indicates failure, a warning, or a special condition. The meaning of individual non-zero values should be documented. A command's status is immediately available through `$?`, so it must be captured or tested before another command overwrites it.

```sh

if cp -- "$source" "$destination"; then

printf 'Copy completed\n'

else

printf 'Copy failed\n' >&2

exit 1

fi

```

A script should return a meaningful status to its caller. `exit 0` indicates successful completion; `exit 1` often denotes a general failure; `exit 2` is frequently used for incorrect usage, although conventions vary. In automation, a reliable exit status is more important than a reassuring message because schedulers and pipelines normally make decisions from status codes.

Bash's `set -e` attempts to terminate a script when a command fails, and `set -u` treats unset variables as errors. `set -o pipefail` makes a pipeline fail if any component fails rather than only considering the last command. A frequently used Bash header is:

```bash

set -Eeuo pipefail

```

These options improve detection but do not replace judgment. `set -e` has context-dependent exceptions, especially in conditionals, lists, and pipelines. It may terminate a script earlier than intended or fail to protect a logically important command. Critical operations should still be checked explicitly. POSIX `sh` supports `set -e` and `set -u` in common implementations, but `pipefail` is not POSIX.

Input, Output, and Redirection

Every process conventionally has standard input (file descriptor 0), standard output (1), and standard error (2). Redirection separates normal results from diagnostics:

```sh

command >output.txt 2>error.txt

command >>output.txt 2>&1

```

The first form truncates output and stores errors separately. The second appends standard output and redirects standard error to the same destination. In Bash, `&>` is a shorter equivalent, but it is not POSIX.

Pipelines connect standard output to standard input:

```sh

ps -ef | grep '[s]shd'

```

The bracket pattern avoids matching the `grep` command itself, though service-specific tools are usually more dependable than process-text searches. A pipeline should be designed with awareness of exit-status behavior and buffering.

`printf` is generally preferable to `echo` for predictable formatting, because implementations differ in their treatment of options and backslash escapes. For input, `read` should normally use `IFS=` and `-r` when reading a line literally:

```sh

IFS= read -r answer

```

A here-document supplies multiple lines to a command:

```sh

cat <<EOF

Host: $HOSTNAME

Mode: $mode

EOF

```

An unquoted delimiter permits expansion. Quoting the delimiter, as in `<<'EOF'`, suppresses expansion and is useful for literal configuration or script fragments.

Loops and Repetition

A `for` loop iterates over a list of words:

```sh

for user in alice bob carol; do

printf 'Checking %s\n' "$user"

done

```

This is appropriate when the list is deliberately constructed. It is not automatically safe for arbitrary filenames. A `while` loop is suitable for streams and condition-controlled repetition:

```sh

while IFS= read -r line; do

printf 'Line: %s\n' "$line"

done < "$input_file"

```

Input redirection avoids a pipeline subshell issue found in many POSIX shells: variables modified inside the right side of a pipeline may not be available after the loop. Bash can use process substitution for some cases, but that is Bash-specific.

`until` repeats until a condition succeeds. `break` exits a loop, and `continue` skips to its next iteration. These controls should support a clear invariant rather than conceal complicated logic. A retry loop, for example, should have a maximum attempt count and an explicit delay so that a failed dependency does not produce an infinite resource-consuming process.

Functions and Reusable Design

Functions package related operations and reduce duplication:

```sh

log_info() {

printf '%s\n' "INFO: $*"

}

require_file() {

if [ ! -f "$1" ]; then

printf 'Missing file: %s\n' "$1" >&2

return 1

fi

}

```

A function returns a status, normally the status of its final command unless `return` is used. Functions share variables with the calling shell in POSIX syntax. Bash supports `local`, which should be used to avoid accidental modification of global variables:

```bash

get_port() {

local service=$1

printf '%s\n' "${PORT_MAP[$service]}"

}

```

The preceding example is Bash-specific because it uses an associative array. Function interfaces should document required arguments, output behavior, side effects, and return statuses. Good scripts separate acquisition, validation, action, and reporting. This separation makes functions easier to test and allows a dry-run mode to report intended changes without performing them.

Arrays: Bash Convenience and Portability Boundary

POSIX shell has no standard array type. Bash indexed arrays are useful for preserving a list of arguments without word-splitting:

```bash

hosts=(web01 web02 db01)

for host in "${hosts[@]}"; do

printf 'Host: %s\n' "$host"

done

```

Bash associative arrays map keys to values:

```bash

declare -A ports=([http]=80 [https]=443)

printf '%s\n' "${ports[https]}"

```

The quoted form `"${array[@]}"` expands to one word per element, preserving boundaries. `"${array[*]}"` expands to a single word containing elements joined by the first character of `IFS`. This distinction is important when passing arrays to commands.

If portability matters, use positional parameters, line-oriented files, or carefully delimited data instead of Bash arrays. Do not describe a script as POSIX-compliant if it uses arrays, `[[`, process substitution, or other Bash-only facilities.

Portability, Security, and Maintainability

Portability is a design decision, not an accidental property. A POSIX script should avoid Bash-only syntax and should not assume GNU command options on systems that may use other implementations. It should also avoid relying on the current working directory, locale-specific output, or an unrestricted `PATH`. Where appropriate, set a controlled `PATH` and invoke commands consistently.

Security risks often arise from untrusted input. Never construct shell code by evaluating user data with `eval`; it can turn data into commands. Avoid interpolating untrusted values into commands, SQL statements, or configuration fragments without a suitable escaping strategy. Temporary files should be created with secure utilities and permissions rather than predictable names in shared directories. Scripts that require privilege should minimize the privileged portion, validate arguments, and avoid running an entire workflow as root.

Race conditions may occur when a script checks a file and later uses it, because another process can change it between operations. Atomic file creation, restrictive permissions, and appropriate operating-system primitives reduce this risk. Destructive commands should support validation, explicit scope, logging, and where feasible a dry-run mode.

Static analysis tools such as ShellCheck identify quoting defects, suspicious tests, unreachable code, and portability issues. Formatting tools such as shfmt can standardize Bash code. Version control, peer review, automated tests, and shell tracing (`set -x`, used cautiously because it may reveal secrets) are part of a professional scripting lifecycle. Secrets should not be printed in logs or exposed through command traces.

An Illustrative Case: Reliable Log Retention Automation

Background

A fictional online retailer operates Linux application hosts. Each host writes compressed and uncompressed logs to `/srv/app/logs`. Disk alerts occur when old logs are not removed, but an earlier script occasionally deleted files outside the intended directory because it relied on an unquoted wildcard and an incorrect working directory.

Situation and Analysis

The operations team requires a script that removes regular files older than fourteen days, reports what it does, returns a failure status if the target directory is unavailable, and supports a dry-run mode. The team also wants the script to work on standard Linux hosts without depending on a particular application framework.

The principal risks are an incorrect path, accidental traversal into another directory, filenames containing spaces, insufficient permissions, and inability to distinguish successful completion from partial failure. A broad command such as `rm -f /srv/app/logs/*` is unsuitable because it has no age criterion and may include directories or unexpected entries.

Approach and Solution

A Bash implementation can be structured as follows:

```bash

#!/usr/bin/env bash

set -Eeuo pipefail

log_dir=${1:-/srv/app/logs}

dry_run=${DRY_RUN:-0}

if [[ ! -d "$log_dir" ]]; then

printf 'Not a directory: %s\n' "$log_dir" >&2

exit 2

fi

if [[ "$dry_run" == 1 ]]; then

printf 'Dry run: files eligible for deletion:\n'

find "$log_dir" -maxdepth 1 -type f -mtime +14 -print

else

find "$log_dir" -maxdepth 1 -type f -mtime +14 -print -delete

fi

```

This is intentionally Bash-specific because it uses `[[` and Bash options. The directory is passed as a quoted argument, `find` is constrained to one directory level, and only regular files older than the selected age are targeted. In a production implementation, the team would additionally decide whether symbolic links, deletion failures, filesystem boundaries, and audit logging require special treatment. A portable version could replace `[[` with POSIX tests and omit Bash-only options, while retaining quoted arguments and a carefully specified `find` expression.

Outcome

The revised design makes the target explicit, allows inspection before deletion, and provides a non-zero status when the target is invalid or a deletion operation fails. It can be reviewed in version control and invoked by a scheduler with a predictable contract.

Learning Points

The case demonstrates that scripting quality depends on more than command knowledge. Safe automation requires precise scope, input validation, quoting, controlled side effects, useful status codes, and an operational mode that permits verification before change.

Common Errors and Professional Practices

Frequent beginner errors include assigning with spaces, forgetting quotes, using `echo` for data that may begin with `-n`, comparing numbers as strings, assuming every command failure terminates a script, and placing a pipeline loop where variable changes are expected to persist. Another error is using `for file in $(find ...)`, which breaks on whitespace and special characters.

A disciplined development process helps prevent these defects:

1. Define the script's interface, assumptions, and return-status contract.

2. Select POSIX shell or Bash before writing syntax.

3. Validate arguments and environmental prerequisites.

4. Quote expansions unless word splitting or globbing is intentional.

5. Separate computation from destructive actions.

6. Test normal, empty, malformed, missing, and permission-denied cases.

7. Run static analysis and review changes before deployment.

8. Log useful events without disclosing credentials or sensitive data.

Shell is excellent for orchestration and for combining mature system utilities. It becomes less suitable when data structures, concurrency, complex error recovery, or large-scale parsing dominate the problem. In those cases, Python, Go, or a specialized automation platform may offer clearer semantics. Choosing another language is not a failure of shell scripting; it is an engineering decision based on complexity, supportability, and risk.

Contemporary Use in DevOps

Shell scripts remain common in container entrypoints, CI runners, image-building steps, infrastructure bootstrap procedures, health checks, and incident-response tooling. Containers increase the importance of signal handling and predictable exit statuses: a wrapper that does not correctly forward termination signals can prevent graceful shutdown. Modern pipelines also emphasize immutable artifacts, policy checks, reproducible environments, and secret-management systems rather than embedding credentials in scripts.

Automation increasingly combines shell with configuration management and workflow engines. Shell is often the final-mile mechanism that invokes a package manager, validates a generated file, or performs a host-level action. The emerging best practice is to keep such scripts small, observable, idempotent where possible, and surrounded by tests and policy controls. AI-assisted code generation may accelerate drafting, but generated scripts still require human review for quoting, privilege boundaries, portability, destructive behavior, and hidden assumptions.

Chapter Summary

Shell scripting converts command-line procedures into repeatable programs for Linux administration and DevOps automation. Reliable scripts begin with an explicit interpreter and a deliberate choice between POSIX portability and Bash features. Variables and expansions provide data handling, but quoting is essential to preserve argument boundaries and prevent unintended filename expansion. Tests, conditionals, loops, functions, arrays, redirection, pipelines, and exit statuses provide the basic programming model.

Professional scripts validate inputs, distinguish standard output from diagnostics, return meaningful statuses, control privileges, avoid unsafe evaluation, and address filenames, race conditions, secrets, and portability. Bash options and static-analysis tools can improve reliability but do not replace explicit reasoning. The log-retention case illustrates how precise scope, dry-run behavior, and operational contracts transform a fragile command sequence into responsible automation.

Key Terms

Shell: A command interpreter that reads and executes commands.

Script: A text file containing commands and programming constructs for an interpreter.

Shebang: The initial interpreter-selection line beginning with `#!`.

POSIX shell: A shell language conforming to the portable Unix shell standard.

Bash: The Bourne Again Shell, providing POSIX features and additional extensions.

Variable: A named shell value, normally stored as text.

Expansion: The shell's transformation of written syntax into command arguments.

Parameter expansion: Substitution of a variable or positional parameter's value.

Command substitution: Replacement of `$(command)` with command output.

Quoting: Syntax used to control interpretation, splitting, and expansion of text.

Exit status: A numeric result returned by a command or script.

Pipeline: A sequence in which one command's output becomes another's input.

Redirection: Reassignment of a process's standard input or output streams.

Function: A named reusable group of shell commands.

Array: An indexed or associative collection supported by Bash but not standard POSIX shell.

Globbing: Expansion of wildcard patterns into matching pathnames.

Here-document: A redirection form that supplies embedded multiline text to a command.

Idempotence: The property that repeating an operation produces the same intended state.

ShellCheck: A static-analysis tool for identifying common shell-script defects.

Dry run: An execution mode that reports intended actions without performing changes.

Review Questions

Short Answer Questions

1. What is the purpose of a shebang in a shell script?

2. Distinguish `$@` from `$*` when quoted.

3. Why should variable expansions usually be enclosed in double quotes?

4. What do standard output and standard error represent?

5. What does a zero exit status conventionally indicate?

Long Answer Questions

1. Explain shell expansion, including parameter, command, arithmetic, and filename expansion, and discuss the risks of unquoted expansion.

2. Compare POSIX shell and Bash with reference to arrays, conditional syntax, options, and portability.

3. Describe a systematic approach to designing a robust administrative script, from interface definition through testing and deployment.

4. Explain how functions, loops, redirection, and exit statuses contribute to modular shell programs.

5. Discuss security and governance concerns in shell scripting, including privilege, temporary files, secrets, untrusted input, and auditability.

Analytical / Application Questions

1. A script processes filenames using `for file in $(find /data -type f)`. Analyze the failure modes and propose a safer design for the target shell environment.

2. Design the control flow for a deployment pre-check script that validates disk space, verifies a configuration file, reports diagnostics, and returns distinguishable statuses for usage errors and operational failures.

3. Evaluate whether a Bash script using associative arrays should be rewritten as POSIX shell for a heterogeneous Unix estate. Identify the technical and organizational factors that should guide the decision.

22. Multiple Choice Questions with Answers

Question 1

Which command preserves each positional argument as a separate word when forwarding all script arguments?

A. echo $*

B. "$@"

C. "$*"

D. $#

Correct Answer: B. "$@"

Question 2

What is the main purpose of quoting "$filename" in a command?

A. It converts the filename to an absolute path

B. It prevents word splitting and unintended pathname expansion

C. It makes the file executable

D. It changes the command's exit status

Correct Answer: B. It prevents word splitting and unintended pathname expansion

Question 3

Which feature is not part of standard POSIX shell syntax?

A. case statements

B. while loops

C. Bash associative arrays

D. Functions

Correct Answer: C. Bash associative arrays

Question 4

What does a shell command conventionally communicate with exit status zero?

A. The command succeeded

B. The command was interrupted

C. The command received no input

D. The command requires root privileges

Correct Answer: A. The command succeeded

Question 5

Why is `printf` generally preferred to `echo` in portable scripts?

A. It automatically encrypts output

B. It has more predictable formatting behavior across implementations

C. It always writes to standard error

D. It prevents all command injection

Correct Answer: B. It has more predictable formatting behavior across implementations

Question 6

Which test checks whether a path is a regular file?

A. [ -d "$path" ]

B. [ -x "$path" ]

C. [ -f "$path" ]

D. [ -n "$path" ]

Correct Answer: C. [ -f "$path" ]

Question 7

What problem does `set -o pipefail` address in Bash?

A. It disables filename expansion

B. It makes a pipeline reflect failure from an earlier component

C. It redirects errors to a file

D. It creates an array of pipeline results

Correct Answer: B. It makes a pipeline reflect failure from an earlier component

Question 8

Which design is safest for a log-cleanup script?

A. Use an unquoted wildcard from the current directory

B. Use `eval` to construct an `rm` command

C. Validate the directory and constrain a carefully specified file search

D. Run every command as root without checks

Correct Answer: C. Validate the directory and constrain a carefully specified file search

Question 9

What is the effect of a quoted here-document delimiter such as `<<'EOF'`?

A. Variables and command substitutions in the body are not expanded

B. The body is automatically compressed

C. The command receives standard error instead of standard input

D. The delimiter is treated as a shell variable

Correct Answer: A. Variables and command substitutions in the body are not expanded

Question 10

Why can `for file in $(find ...)` be unreliable for arbitrary filenames?

A. It cannot execute any external command

B. Command substitution and word splitting can lose filename boundaries

C. It always follows symbolic links recursively

D. It changes all filenames to uppercase

Correct Answer: B. Command substitution and word splitting can lose filename boundaries

Linux System Administration and Shell Scripting
Chapter 9 of 15