A script that hard-codes its configuration is a script you cannot reuse. Every professional-grade shell tool — from kubectl to aws to your own deployment automation — accepts arguments that change its behavior at call time. In this lesson you will learn the full interface-design toolkit for Bash scripts: positional parameters, the getopts built-in for POSIX-style flags, the canonical usage-function pattern, and the read built-in for prompting a human operator. These four building blocks are enough to build CLIs indistinguishable from standard Unix tools.
Positional Parameters
When Bash invokes a script, every word after the script name lands in a numbered variable. $1 is the first argument, $2 the second, and so on. $0 is the script's own name. $# is the count of arguments. "$@" expands to all arguments as separate quoted words — always prefer this over $* when forwarding arguments, because it preserves whitespace inside individual values.
#!/usr/bin/env bash
set -euo pipefail
# $1 = environment (required) $2 = image tag (optional)
ENVIRONMENT="${1:-}"
IMAGE_TAG="${2:-latest}"
# Fail fast with a clear message instead of a cryptic error later
if [[ -z "$ENVIRONMENT" ]]; then
echo "ERROR: environment argument is required" >&2
exit 1
fi
echo "Deploying image ${IMAGE_TAG} to ${ENVIRONMENT}"
# Shift removes $1 and renumbers the rest; useful after consuming known args
shift
echo "Remaining args after shift: $*"
# Iterating over all arguments safely
for arg in "$@"; do
echo " arg: ${arg}"
done
The ${1:-} form gives you an empty string when the argument is absent, so your -z check fires cleanly. The harder-failing form ${1:?message} makes Bash itself print the message and exit immediately — useful inside functions but can produce confusing output at the top level of a script.
Key idea — always validate before use: Never assume the caller passed the right number of arguments. Check early with a guard clause or a usage function, and exit with a non-zero code (typically 1 or 2) and a message to stderr. This is what every well-written Unix tool does, and it is what operators expect when they pipe your script into larger workflows.
The Usage Function Pattern
Every script that accepts arguments needs a usage function. It documents the interface, is printable on -h/--help, and is called automatically when validation fails. This is the pattern used by HashiCorp, GitHub Actions runner, and most open-source infrastructure tooling:
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_NAME="$(basename "$0")"
usage() {
cat <<EOF
Usage: ${SCRIPT_NAME} [OPTIONS] <environment>
Deploy the application to the specified environment.
Arguments:
environment Target environment: dev | staging | prod
Options:
-t, --tag TAG Docker image tag to deploy (default: latest)
-n, --dry-run Print actions without executing them
-v, --verbose Enable verbose output
-h, --help Show this help message and exit
Examples:
${SCRIPT_NAME} staging
${SCRIPT_NAME} -t v2.3.1 prod
${SCRIPT_NAME} --dry-run prod
EOF
}
# Guard: print usage and exit 0 when -h/--help is the first arg
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
Notice cat <<EOF — a regular heredoc (not NOWDOC) so that ${SCRIPT_NAME} expands correctly inside the usage text. The usage function is the first thing a new operator reads; make it complete and accurate.
Parsing Flags with getopts
getopts is the POSIX-standard Bash built-in for parsing short options (-v, -t TAG). It handles option bundling (-vn), required arguments, and the -- end-of-options sentinel. It does not handle long options (--verbose) natively; for those, use a manual while/case loop or getopt (the external command with two t's). In practice, most production scripts at large companies use the manual pattern because it handles both short and long forms cleanly.
#!/usr/bin/env bash
set -euo pipefail
# --- defaults ---
IMAGE_TAG="latest"
DRY_RUN=false
VERBOSE=false
# --- getopts: short options only ---
# A colon after a letter means that option requires an argument.
# A leading colon enables silent error handling (no built-in error messages).
while getopts ":t:nvh" opt; do
case "$opt" in
t) IMAGE_TAG="$OPTARG" ;;
n) DRY_RUN=true ;;
v) VERBOSE=true ;;
h) usage; exit 0 ;;
:) echo "ERROR: option -${OPTARG} requires an argument" >&2; exit 2 ;;
\?) echo "ERROR: unknown option -${OPTARG}" >&2; exit 2 ;;
esac
done
# After getopts, shift processed options away so $1 is now the first positional
shift $(( OPTIND - 1 ))
ENVIRONMENT="${1:?$(usage; echo 'ERROR: environment is required')}"
echo "Tag=${IMAGE_TAG} DryRun=${DRY_RUN} Verbose=${VERBOSE} Env=${ENVIRONMENT}"
How getopts processes flags one at a time, dispatching to a case branch, then leaves positional arguments for use after the shift.
Handling Long Options with a while/case Loop
For scripts that will be used frequently by humans, long options (--dry-run, --tag) dramatically improve readability. The standard idiom is a manual while true; do case "$1" in ... esac; done loop:
#!/usr/bin/env bash
set -euo pipefail
IMAGE_TAG="latest"
DRY_RUN=false
VERBOSE=false
while [[ $# -gt 0 ]]; do
case "$1" in
-t|--tag)
IMAGE_TAG="${2:?--tag requires an argument}"
shift 2
;;
-n|--dry-run)
DRY_RUN=true
shift
;;
-v|--verbose)
VERBOSE=true
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift # explicit end-of-options sentinel
break
;;
-*)
echo "ERROR: unknown option: $1" >&2
usage
exit 2
;;
*)
break # first non-option argument; stop parsing flags
;;
esac
done
ENVIRONMENT="${1:?ERROR: environment argument is required}"
shift || true
echo "Deploying ${IMAGE_TAG} to ${ENVIRONMENT} | dry-run=${DRY_RUN} verbose=${VERBOSE}"
Pro practice — exit code 2 for usage errors: POSIX convention is exit code 1 for runtime errors and exit code 2 for "incorrect usage" (wrong arguments, unknown flag). Many CI systems and shell scripts test $? and can distinguish the two. Always exit with 2 when the caller passed bad arguments, and print the usage or a pointer to --help.
Interactive Input with read
Automated pipelines should never require interactive input — but scripts run by a human operator (provisioning, database migrations, secret rotation) sometimes need a confirmation prompt or a value the script cannot derive itself. The read built-in handles this cleanly.
#!/usr/bin/env bash
set -euo pipefail
# Basic prompt — stores the answer in REPLY by default
read -r -p "Enter target environment [dev/staging/prod]: " ENVIRONMENT
# Read with a default value — press Enter to accept
read -r -p "Image tag [latest]: " IMAGE_TAG
IMAGE_TAG="${IMAGE_TAG:-latest}"
# Read a password without echoing characters to the terminal
read -r -s -p "Database password: " DB_PASSWORD
echo "" # newline after hidden input
# Read with a timeout — returns non-zero if time expires
if ! read -r -t 30 -p "Continue with deployment? [y/N]: " CONFIRM; then
echo ""
echo "Timed out. Aborting." >&2
exit 1
fi
# Guard confirmation with a regex match
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "Deployment cancelled by operator."
exit 0
fi
echo "Proceeding: env=${ENVIRONMENT} tag=${IMAGE_TAG}"
Key read flags to know: -r disables backslash interpretation (always use it), -p prints a prompt, -s suppresses echoing (passwords), -t N sets a timeout in seconds, and -a reads whitespace-separated words into an array.
Production pitfall — interactive input in CI: When a script containing read runs inside a CI pipeline (GitHub Actions, Jenkins, GitLab CI), stdin is not a terminal and read immediately returns an empty string or times out. Always check [[ -t 0 ]] (is stdin a terminal?) before prompting, or provide a --yes / -y flag that skips all prompts for non-interactive use. Mixing interactive and non-interactive paths in the same script is the standard pattern used by tools like helm upgrade and kubectl apply.
Putting It All Together: A Production-Ready Script Skeleton
Combining everything from this lesson produces the skeleton used by real infrastructure scripts at big-tech companies. The pattern is: usage function, argument defaults, flag-parsing loop, positional validation, optional confirmation prompt, then the actual work:
#!/usr/bin/env bash
# rollback.sh — Roll back a service to a previous image tag
# Usage: ./rollback.sh [OPTIONS] <service> <tag>
set -euo pipefail
readonly SCRIPT_NAME="$(basename "$0")"
usage() {
cat <<EOF
Usage: ${SCRIPT_NAME} [OPTIONS] <service> <tag>
Roll back <service> to Docker image <tag> in the current cluster.
Options:
-n, --namespace NS Kubernetes namespace (default: default)
-y, --yes Skip confirmation prompt (for CI/automation)
-v, --verbose Enable verbose kubectl output
-h, --help Show this help
Examples:
${SCRIPT_NAME} api-server v1.9.2
${SCRIPT_NAME} --namespace payments --yes api-server v1.9.2
EOF
}
# --- defaults -------------------------------------------------
NAMESPACE="default"
YES=false
VERBOSE=false
# --- flag parsing ---------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--namespace) NAMESPACE="${2:?--namespace requires a value}"; shift 2 ;;
-y|--yes) YES=true; shift ;;
-v|--verbose) VERBOSE=true; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) echo "ERROR: unknown option $1" >&2; usage; exit 2 ;;
*) break ;;
esac
done
# --- positional validation ------------------------------------
SERVICE="${1:?$(usage; echo 'ERROR: service argument is required')}"
TAG="${2:?$(usage; echo 'ERROR: tag argument is required')}"
# --- confirmation gate (skip in CI) --------------------------
if [[ "$YES" == false ]]; then
read -r -p "Roll back ${SERVICE} to ${TAG} in namespace ${NAMESPACE}? [y/N]: " CONFIRM
[[ "$CONFIRM" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
fi
# --- actual work ----------------------------------------------
KUBECTL_FLAGS=()
[[ "$VERBOSE" == true ]] && KUBECTL_FLAGS+=(-v=6)
echo "Rolling back ${SERVICE} to image tag ${TAG} ..."
kubectl set image deployment/"${SERVICE}" \
"${SERVICE}=${SERVICE}:${TAG}" \
--namespace "${NAMESPACE}" \
"${KUBECTL_FLAGS[@]}"
echo "Done. Monitor rollout with:"
echo " kubectl rollout status deployment/${SERVICE} -n ${NAMESPACE}"
This skeleton — usage function, defaults, flag loop, positional guard, CI-aware confirmation, then work — is the template your team should copy for every new infrastructure script. It behaves correctly whether called by a human, a CI pipeline, or another script.