If you spend any amount of time in a Linux terminal, you have typed the same sequence of commands more than once. Maybe you manually check disk usage across a set of directories every morning, or you run the same three-step deployment process every time you push a release. Bash scripting exists to eliminate that repetition. It takes the commands you already know and chains them into reusable, automated workflows that run without your constant supervision.
There is a second, less obvious reason to learn Bash scripting: your scripts can also become attack vectors if you write them carelessly. A script that does not validate input, uses predictable temp file names, or passes unquoted variables to commands can hand an attacker command execution on a silver platter. The same knowledge that makes you productive also makes your systems defensible. Both angles are covered here.
This guide is written for people who are comfortable running commands in a terminal but have never written a Bash script -- or have written a few quick one-offs without really understanding the structure behind them. By the end, you will have the foundational knowledge to write scripts that handle variables, make decisions with conditionals, iterate with loops, accept user input, and handle errors gracefully -- and you will know which common mistakes turn beginner scripts into security liabilities.
What Is Bash, and Why Script with It?
Bash stands for Bourne Again SHell. It was created by Brian Fox for the GNU Project and first released as a public beta on June 8, 1989, as a free replacement for the original Bourne shell (sh), which Stephen Bourne developed at Bell Labs and distributed with Version 7 Unix in 1979. Brian Fox announced the beta release to the GNU development community that June, and Chet Ramey -- who remains the primary maintainer today -- began contributing that same year, in 1989, after he found an early copy of Bash, made job control work, and sent his changes back to Fox. Today, Bash is the default interactive shell on the majority of Linux distributions and remained the default on macOS until Catalina (10.15) in 2019, when Apple switched to Zsh -- primarily for licensing reasons (Bash 3.2 was the last version with an Apple-compatible license). On macOS, /bin/bash is still present but frozen at version 3.2, which lacks many features available in Bash 4 and 5. Bash 5.3, the current stable release, was officially published on July 3, 2025, by Chet Ramey on behalf of the GNU Project. The official GNU Bash page at gnu.org/software/bash tracks current releases and patches. Most production Linux servers run Bash 5.1 or 5.2, with 5.3 rolling into distributions progressively.
A Bash script is simply a plain text file containing a series of commands that Bash executes in order. Instead of typing those commands one at a time, you write them into a file, make it executable, and run the whole sequence at once. This is scripting at its most fundamental: telling the computer to do what you would have done manually, but faster and without mistakes.
The real power of Bash scripting is that it does not require installing anything new. Every Linux server already has Bash. Every CI/CD pipeline can run shell scripts. Every cron job is, at its core, a scheduled Bash command. Learning Bash scripting is not about picking up some niche tool -- it is about unlocking the full potential of the environment you are already working in.
What makes Bash uniquely valuable compared to writing everything in Python or Ruby is the zero-friction integration with every command-line tool on the system. Bash is the glue that connects find, grep, awk, curl, systemctl, and thousands of other utilities into coherent workflows. For tasks that are fundamentally about orchestrating those tools, Bash is often the right choice even when a higher-level language is available.
Bash is not the only shell available. Alternatives include Zsh (default on macOS since Catalina), Fish (known for user-friendly features), Dash (used as /bin/sh on many Debian-based systems for speed), and POSIX sh for maximum portability. Bash remains the standard for scripting because of its universal availability on Linux systems and its balance of features and compatibility. Scripts written for Bash will run on virtually any Linux server you encounter, but note that scripts using Bash 4+ features (associative arrays, mapfile, etc.) will not work on macOS's system Bash without using a Homebrew-installed version.
Your First Bash Script
Every Bash script starts with a shebang line$ shebangThe first line of a script, starting with #! followed by the path to the interpreter -- e.g. #!/bin/bash. The kernel reads this line and knows which program to hand the file to. Without it, the OS may try the wrong shell or refuse to execute the file.See FAQ: What is a shebang line?. This special line tells the operating system which interpreter to use when executing the file. Without it, the system may not know how to handle your script, or worse, it might try to interpret it with a different shell entirely.
#!/bin/bash echo "Hello, world!" echo "Today is $(date '+%A, %B %d, %Y')." echo "You are logged in as: $(whoami)" echo "Current directory: $(pwd)"
The #!/bin/bash on line one is the shebang. The #! tells the kernel this is a script, and /bin/bash is the path to the interpreter. Lines beginning with # (after the shebang) are comments -- ignored by the interpreter but essential for anyone reading your code later, including your future self.
To actually run this script, you need to make it executable and then invoke it:
The chmod +x command adds the executable permission to the file. The ./ prefix tells Bash to look for the script in the current directory rather than searching your $PATH. Notice the $(command) syntax inside the echo statements -- this is called command substitution, and it executes the command inside the parentheses and inserts its output into the string. You will use this pattern constantly in real scripts.
Use #!/usr/bin/env bash instead of #!/bin/bash if you want maximum portability. The env command searches your $PATH for the Bash binary, which handles cases where Bash is installed in a non-standard location.
What does the #! at the top of a script actually do, and what happens if you omit it?
#! (shebang) tells the kernel which interpreter to hand the file to -- e.g. #!/bin/bash means "run this with Bash." Without it, the OS may run the script with /bin/sh instead, which may not support Bash-specific syntax like [[ ]] or arrays, causing silent failures or errors.What are the two commands required to create and run a new script called myscript.sh?
chmod +x myscript.sh adds the executable permission, then ./myscript.sh runs it. The ./ is required because the current directory is not in $PATH by default -- without it, Bash will not find the script.What does $(command) syntax do inside a string?
$(command) expression with that command's output. For example, echo "Today is $(date '+%A, %B %d, %Y')" prints the full date. Note the single quotes inside the $() — they are passed to date, not interpreted by Bash.Variables and Data
Variables in Bash work differently than in many programming languages. There are no type declarations -- everything is a string unless the context requires arithmetic. Assigning a variable is straightforward, but there is one critical rule: no spaces around the equals sign.
#!/bin/bash # Variable assignment -- no spaces around the = NAME="webserver-01" LOG_DIR="/var/log/myapp" MAX_RETRIES=5 TIMESTAMP="$(date +%Y%m%d_%H%M%S)" # Referencing variables with $ echo "Server: $NAME" echo "Logging to: $LOG_DIR" echo "Backup file: ${NAME}_backup_${TIMESTAMP}.tar.gz" # Reading user input read -p "Enter the deployment environment: " ENVIRONMENT echo "Deploying to: $ENVIRONMENT"
The curly brace syntax ${NAME} is optional when the variable name stands alone, but becomes essential when you need to distinguish the variable from surrounding text. In the example above, ${NAME}_backup_${TIMESTAMP} would not work correctly without braces -- Bash would try to find a variable called NAME_backup_ instead.
Bash also provides special variables that are set automatically. The ones you will use repeatedly include $0 (the script's own filename), $1 through $9 (positional arguments passed to the script), $# (the number of arguments), $? (the exit status of the last command), and $$ (the script's process ID). These are the building blocks for writing scripts that respond to input and report their own status.
One often-overlooked feature is parameter expansion with defaults. Rather than writing a separate if block to handle an unset variable, you can use the ${VAR:-default} syntax to supply a fallback inline. This is especially useful for optional arguments:
#!/bin/bash # ${VAR:-default} -- use default if VAR is unset or empty # ${VAR:=default} -- assign default to VAR if unset or empty # ${VAR:?message} -- exit with message if VAR is unset or empty ENVIRONMENT="${1:-production}" # default to "production" if no arg given LOG_LEVEL="${LOG_LEVEL:-info}" # respect env var, fall back to "info" REQUIRED_ARG="${2:?Usage: deploy.sh <env> <version>}" echo "Deploying to: $ENVIRONMENT at log level: $LOG_LEVEL"
The third form -- ${VAR:?message} -- is a clean way to enforce required arguments: if the variable is unset or empty, the script exits immediately with your custom error message. This is more concise than an equivalent if block and self-documents intent at the point of use.
DEPLOY_DIR="/opt/my app" (note the space in the path). Later it runs rm -rf $DEPLOY_DIR without quotes. What actually gets deleted?
$DEPLOY_DIR. The value /opt/my app splits into two tokens: /opt/my and app. The command becomes rm -rf /opt/my app — which attempts to delete two separate paths: the directory /opt/my and whatever app resolves to in the current working directory. Neither is the intended target. With the correct form rm -rf "$DEPLOY_DIR", the quoted expansion is treated as a single argument and the correct directory is targeted.
Always double-quote your variables: "$VARIABLE" instead of $VARIABLE. Unquoted variables are subject to word splitting and glob expansion, which can cause subtle and dangerous bugs -- especially when filenames contain spaces or special characters. This is not just a style preference; it is a security and correctness requirement. The single most common source of bugs in beginner scripts is an unquoted variable. When in doubt: quote it.
The read command above collects user input into $ENVIRONMENT. If that variable is later passed to a command without quoting or validation -- e.g. deploy.sh $ENVIRONMENT -- an attacker who controls the input can inject arbitrary shell commands. Every variable that touches user input is a potential injection vector. The Security section covers how to close this with regex whitelists and quoting. Keep it in mind as you build from here.
What is wrong with this assignment: NAME = "webserver-01"?
= sign are illegal in Bash. Bash interprets NAME as a command name, not a variable assignment. The correct form is NAME="webserver-01" with no spaces.What does ${APP:-myapp} expand to when APP is unset?
myapp without modifying the variable. If APP is set to a non-empty string, that value is used instead. This is parameter expansion with default.Why does echo ${NAME}_backup need curly braces?
NAME_backup (the underscore is a valid identifier character), finds nothing, and expands to an empty string. Curly braces delimit the variable name so _backup is treated as literal text."$VAR" — is not a style preference. It is the same principle that shows up in array expansion, parameter expansion defaults, and the entire Security section. Every time you quote a variable, you are making a deliberate choice: this is data, not code to be re-parsed. The moment you stop quoting is the moment Bash starts making decisions for you — and those decisions can delete files, skip conditions, or hand an attacker a shell.
Conditionals: Making Decisions
Scripts become genuinely useful when they can make decisions. Bash uses if statements to evaluate conditions and execute different code paths based on the result. The syntax looks a bit unusual compared to other languages, but the pattern is consistent once you internalize it.
#!/bin/bash # Alert if disk usage exceeds a threshold THRESHOLD=80 USAGE=$(df -P / | tail -1 | awk '{print $5}' | tr -d '%') if [ "$USAGE" -gt "$THRESHOLD" ]; then echo "WARNING: Disk usage is at ${USAGE}% (threshold: ${THRESHOLD}%)" echo "Top space consumers:" du -sh /var/log/* 2>/dev/null | sort -rh | head -5 elif [ "$USAGE" -gt 60 ]; then echo "NOTICE: Disk usage is at ${USAGE}% -- approaching threshold." else echo "OK: Disk usage is at ${USAGE}%." fi
The square brackets [ ] are actually a shorthand for the test command. The spaces inside the brackets are mandatory -- [$USAGE -gt $THRESHOLD] without spaces will produce an error. For numeric comparisons, use -gt (greater than), -lt (less than), -eq (equal), -ne (not equal), -ge (greater than or equal), and -le (less than or equal).
For string comparisons and more complex expressions, Bash provides the double-bracket syntax [[ ]], which supports pattern matching and regular expressions. File test operators are another essential tool: -f checks if a file exists, -d checks for a directory, -r checks read permissions, and -s checks if a file is non-empty.
#!/bin/bash set -eEuo pipefail if [[ ! -f "$CONFIG_FILE" ]]; then echo "ERROR: Config file not found at $CONFIG_FILE" exit 1 elif [[ ! -r "$CONFIG_FILE" ]]; then echo "ERROR: Config file exists but is not readable." exit 1 else echo "Config loaded from $CONFIG_FILE" # A .yml file cannot be sourced directly -- use yq or a parser: # APP_PORT=$(yq '.port' "$CONFIG_FILE") fi
The source command executes its argument as Bash code. A .yml or .yaml file is not valid Bash syntax — sourcing it will throw a syntax error at best, and run arbitrary commands at worst if the file can be written by an attacker. To read values from a YAML config file, use a dedicated parser: yq '.port' "$CONFIG_FILE" or python3 -c "import yaml,sys; d=yaml.safe_load(open('$CONFIG_FILE')); print(d['port'])". If you want a config file that can be safely sourced, use a plain KEY=value format (no YAML syntax) and restrict its permissions with chmod 600.
if [ $FILENAME == $OTHER ]; then and FILENAME contains a filename with a space in it, like my file.log?
$FILENAME expands to my file.log, which the shell splits into two tokens: my and file.log. The [ command now receives too many arguments and throws: [: too many arguments. The conditional is never evaluated correctly. The fix is "$FILENAME" — quotes prevent word splitting. In [[ ]] this is even more forgiving, but quoting remains the correct habit regardless.
# No quotes -- word-splitting hazard if [ $FILENAME == $OTHER ]; then # String comparison typo risk if [ $STATUS = "running" ]; then # Pattern matching -- doesn't work in [ ] if [ $VAR =~ ^[0-9]+$ ]; then
# Quoted, safe if [[ "$FILENAME" == "$OTHER" ]]; then # String comparison, explicit equality if [[ "$STATUS" == "running" ]]; then # Regex matching works natively if [[ "$VAR" =~ ^[0-9]+$ ]]; then
Loops: Repeating Work
Loops are where automation truly begins. Instead of running the same command against ten servers manually, you write a loop that iterates through the list and does the work for you. Bash supports three primary loop constructs: for, while, and until.
The for loop is the one you will reach for in the majority of use cases. It iterates over a list of items -- filenames, server names, numbers -- and executes a block of code for each one.
#!/bin/bash # Clean up log files older than 30 days LOG_DIRS=("/var/log/myapp" "/var/log/nginx" "/var/log/postgresql") DAYS_OLD=30 TOTAL_FREED=0 for dir in "${LOG_DIRS[@]}"; do if [[ -d "$dir" ]]; then echo "Scanning $dir for files older than $DAYS_OLD days..." COUNT=$(find "$dir" -name "*.log" -mtime +"$DAYS_OLD" -type f | wc -l) echo " Found $COUNT files to remove." find "$dir" -name "*.log" -mtime +"$DAYS_OLD" -type f -delete TOTAL_FREED=$(( TOTAL_FREED + COUNT )) else echo " Skipping $dir -- directory not found." fi done echo "Cleanup complete. Removed $TOTAL_FREED log files total."
Notice the array syntax: LOG_DIRS=("item1" "item2" "item3") defines an indexed array, and "${LOG_DIRS[@]}" expands to all elements. The double quotes around the expansion are important -- they ensure that directory paths containing spaces are handled correctly as individual items rather than being split apart.
The while loop is ideal for cases where you need to keep running until a condition changes, such as waiting for a service to become available or processing input line by line:
#!/bin/bash # Wait for a service to respond before proceeding HOST="localhost" PORT=5432 MAX_WAIT=30 WAITED=0 echo "Waiting for $HOST:$PORT to accept connections..." # nc -z works on GNU/BSD nc. On BusyBox (Alpine, minimal containers) use: # while ! bash -c "echo > /dev/tcp/$HOST/$PORT" 2>/dev/null; do while ! nc -z "$HOST" "$PORT" 2>/dev/null; do if [ "$WAITED" -ge "$MAX_WAIT" ]; then echo "ERROR: Timed out after ${MAX_WAIT}s waiting for $HOST:$PORT" exit 1 fi sleep 1 WAITED=$(( WAITED + 1 )) done echo "Connection established after ${WAITED}s."
Why does "${LOG_DIRS[@]}" need double quotes around the expansion?
/var/log/my app -- would be split into two separate loop iterations instead of one. The quotes treat each element as a single token.When would you use a while loop instead of a for loop?
while when you are iterating until a condition changes rather than over a known list -- for example, waiting for a service port to open, reading lines from stdin, or polling until a file appears. for is cleaner when the set of items is known upfront."${LOG_DIRS[@]}" is not just about spaces in filenames — it is your first direct encounter with the quoting discipline that the Security section builds on entirely. Every place you see "${...}" in a script, Bash is being told: treat this as one unit of data, not a command to be re-parsed. That distinction — data vs. command — is the conceptual spine of injection prevention.
Functions: Organizing Your Code
As scripts grow beyond a few dozen lines, they become hard to read and maintain without some form of organization. Functions let you group related commands under a descriptive name, making your scripts modular and reusable. A well-named function also serves as documentation -- validate_input tells you exactly what that block of code does.
#!/bin/bash # A structured deployment script using functions APP_DIR="/opt/myapp" BACKUP_DIR="/opt/backups" log_message() { local LEVEL="$1" local MSG="$2" echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$LEVEL] $MSG" } create_backup() { local BACKUP_FILE="${BACKUP_DIR}/backup_$(date +%Y%m%d_%H%M%S).tar.gz" log_message "INFO" "Creating backup at $BACKUP_FILE" if tar -czf "$BACKUP_FILE" -C "$APP_DIR" .; then log_message "INFO" "Backup created successfully." return 0 else log_message "ERROR" "Backup failed." return 1 fi } pull_latest() { log_message "INFO" "Pulling latest code..." # Use return 1, not exit 1 -- exit inside a function aborts the whole # script immediately, bypassing any caller error-handling logic. cd "$APP_DIR" || { log_message "ERROR" "Cannot cd to $APP_DIR"; return 1; } git pull origin main } restart_service() { log_message "INFO" "Restarting application service..." sudo systemctl restart myapp.service sleep 2 if systemctl is-active --quiet myapp.service; then log_message "INFO" "Service restarted successfully." else log_message "ERROR" "Service failed to start!" return 1 fi } # Main execution flow log_message "INFO" "=== Starting deployment ===" create_backup || exit 1 pull_latest || exit 1 restart_service || exit 1 log_message "INFO" "=== Deployment complete ==="
Notice the local keyword inside functions. Without it, variables are global by default in Bash, which means a variable set inside one function can accidentally overwrite a variable with the same name somewhere else. Using local scopes the variable to the function where it is declared. The return statement sets the function's exit status -- 0 for success, anything else for failure -- which the calling code can then check with || (or) to decide whether to continue.
Error Handling and Defensive Scripting
Bash scripts fail silently by default. If a command in the middle of your script fails, Bash will simply move on to the next line as though nothing happened. In a deployment script or a data processing pipeline, this behavior can be catastrophic. Defensive scripting means anticipating failure and handling it explicitly.
The single most impactful line you can add to any Bash script is set -eEuo pipefail$ set -eEuo pipefailFour safety flags combined: -e exits on any non-zero return, -E ensures the ERR trap is inherited by functions and subshells, -u treats unset variables as errors, and -o pipefail fails a pipeline if any stage fails. Add this near the top of every script.See FAQ: What does set -euo pipefail do?. This enables four safety mechanisms at once:
#!/bin/bash # -e Exit immediately if any command returns a non-zero status # -E ERR trap is inherited by functions and subshells (errtrace) # -u Treat unset variables as an error (prevents typo-based bugs) # -o pipefail A pipeline fails if ANY command in it fails, # not just the last one set -eEuo pipefail # Trap errors and clean up cleanup() { local EXIT_CODE=$? if [ "$EXIT_CODE" -ne 0 ]; then echo "ERROR: Script failed with exit code $EXIT_CODE" >&2 fi # Remove temp files, release locks, etc. rm -f /tmp/myapp_lockfile } trap cleanup EXIT
The trap$ trapA Bash built-in that registers a command or function to execute when a signal is received or when the script exits. trap cleanup EXIT guarantees your cleanup function runs on any exit path, including errors and Ctrl+C.See: Error Handling command registers a function to run when the script exits -- whether it exits normally, because of an error, or because it received a signal like SIGINT (Ctrl+C). This pattern is essential for scripts that create temporary files, acquire locks, or start background processes that need to be cleaned up regardless of how the script ends.
Two related details matter in practice. First, the -E flag (errtrace) ensures the ERR trap is inherited by functions and subshells. Without it, a failure inside a called function silently bypasses the ERR trap because the trap is not propagated down the call stack by default -- the function exits with a non-zero code but the trap never fires until control returns to the top level. Second, trap strings written in single quotes have their variables expanded when the trap fires, not when trap is called. This means trap 'err_handler $LINENO $?' ERR correctly reports the line of the failing command because $LINENO is evaluated at the moment of failure.
#!/bin/bash # -E (errtrace): ERR trap is inherited by functions and subshells. # Without -E, a failure inside a function bypasses the ERR trap entirely. set -eEuo pipefail # Enable debug trace mode conditionally via environment variable # Usage: TRACE=1 ./myscript.sh if [[ "${TRACE-0}" == "1" ]]; then set -x fi # Single-quoted trap strings expand $LINENO and $BASH_COMMAND when the trap # fires, not when trap is registered -- so $LINENO is the failing line. err_handler() { echo "ERROR at line $1 -- exit code $2 -- command: $BASH_COMMAND" >&2 } trap 'err_handler $LINENO $?' ERR
Two details are worth understanding here. First, the -E flag (errtrace) is added to the set line -- without it, an ERR trap set at the top level is not inherited by functions or subshells, which means a failure inside a called function silently bypasses the trap. Second, the trap string is in single quotes, which means $LINENO and $BASH_COMMAND expand when the trap fires, not when trap is called -- so $LINENO correctly reports the line where the failure occurred. The TRACE=1 ./myscript.sh pattern lets you hand debug mode to teammates without modifying the script itself.
trap cleanup EXIT is not only an error handling tool — it is a security control. Without a guaranteed cleanup path, a script that fails mid-execution can leave temporary files, lock files, or partially-written data on disk. An attacker who can trigger a script failure at the right moment can exploit those leftover artifacts. The same trap that removes $TMPFILE on error is the mechanism that closes the symlink attack window demonstrated in the Security section.
set -e active. Your script includes this line outside of any if block:
grep "pattern" /var/log/app.log. The log file exists but the pattern is not found.
What happens next — and is it what you wanted?
grep exits with code 1 when it finds no matches. With set -e active, a non-zero exit code from any standalone command terminates the script immediately — so your script exits right there, silently, as if it failed. Whether this is what you wanted depends on intent: if "no matches" is a normal condition, the script should not stop. The fix is grep "pattern" /var/log/app.log || true — the || true short-circuits to exit code 0 regardless of grep's result, telling Bash this non-zero exit is expected and acceptable.
Be careful with set -e in scripts that intentionally check for command failure. Commands inside if conditions are exempt from -e, but standalone commands that you expect might fail (like grep not finding a match) will cause the script to exit. Use command || true to explicitly allow a non-zero exit status where needed.
What does each flag in set -eEuo pipefail do independently?
-e: exit immediately if any command returns a non-zero status. -E (errtrace): the ERR trap is inherited by functions and subshells -- without this, a failure inside a function bypasses the ERR trap at the top level. -u: treat unset variables as an error (prevents typo-based silent failures). -o pipefail: a pipeline fails if any stage in it fails, not just the last command. Together they make scripts fail loudly instead of silently continuing past errors.Why is trap cleanup EXIT better than placing cleanup code at the end of the script?
trap cleanup EXIT runs the function on any exit path -- including errors caught by set -e, signals like Ctrl+C (SIGINT), and early exit calls anywhere in the script. It is the only reliable way to guarantee cleanup runs.You have a command that legitimately returns exit code 1 (e.g. grep finds no match). How do you allow that without disabling set -e?
|| true to the command: grep "pattern" file || true. The || true means "if the previous command failed, run true instead" -- and true always exits 0. This explicitly allows the non-zero exit without affecting the rest of the script.Real-World Automation Patterns
The concepts covered above -- variables, conditionals, loops, functions, and error handling -- come together in practical scripts that solve real problems. Here is a complete example that monitors a set of web endpoints and sends a summary report. This is the kind of script that runs on a cron schedule and saves an operations team from manually checking service health every morning.
#!/bin/bash # -e is intentionally included; use || true on commands that may return # non-zero in normal operation (e.g. grep finding no matches). set -euo pipefail ENDPOINTS=( "https://api.example.com/health" "https://app.example.com/status" "https://docs.example.com" ) # mktemp creates an unpredictable filename, preventing symlink attacks. # trap ensures the file is removed on any exit path. REPORT_FILE=$(mktemp) trap 'rm -f "$REPORT_FILE"' EXIT FAILURES=0 echo "Health Check Report -- $(date)" > "$REPORT_FILE" echo "================================" >> "$REPORT_FILE" for url in "${ENDPOINTS[@]}"; do HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$url") if [[ "$HTTP_CODE" == "200" ]]; then echo "[ OK ] $url ($HTTP_CODE)" >> "$REPORT_FILE" else echo "[ FAIL ] $url ($HTTP_CODE)" >> "$REPORT_FILE" FAILURES=$(( FAILURES + 1 )) fi done echo "================================" >> "$REPORT_FILE" echo "Total: ${#ENDPOINTS[@]} checked, $FAILURES failed." >> "$REPORT_FILE" if [ "$FAILURES" -gt 0 ]; then cat "$REPORT_FILE" # mail -s "Health Check: $FAILURES failures" [email protected] < "$REPORT_FILE" exit 1 fi echo "All endpoints healthy."
To schedule this script to run every 15 minutes, you would add it to your $ cronA Unix time-based job scheduler. The user-level scheduler is configured with crontab -e. Each entry uses a 5-field schedule: minute, hour, day-of-month, month, day-of-week -- then the command to run. Cron runs jobs in a minimal environment with no $PATH set up, so always use full paths in cron entries.crontab:
# Run health check every 15 minutes # Set PATH explicitly: cron runs with a minimal environment that may not # include /usr/local/bin or other directories your script depends on. */15 * * * * PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /home/ops/scripts/health_check.sh >> /var/log/health_check.log 2>&1
The 2>&1 at the end redirects standard error into standard output, ensuring that both success messages and error messages end up in the log file. Without this, errors from the script would be silently swallowed by cron or sent as system mail that nobody reads.
Best Practices for Maintainable Scripts
Writing a script that works is only half the challenge. Writing one that your colleagues can understand six months later -- or that you yourself can debug at 2 AM during an incident -- requires discipline around a few habits.
Start every script with set -eEuo pipefail. These flags prevent an entire category of silent failures. The -E (errtrace) flag deserves particular mention because it is the one that is easiest to omit: without it, ERR traps are not inherited by functions, which means a failure inside a called function can bypass your trap entirely. Add comments that explain why, not just what. The code already shows what it does; comments should explain the reasoning behind non-obvious choices.
Use meaningful variable names. BACKUP_RETENTION_DAYS=30 is self-documenting; X=30 is not. Define configuration values as variables at the top of the script rather than scattering magic numbers and hardcoded paths throughout the code. This creates a single place to update when paths or thresholds change.
Validate your inputs. If your script expects arguments, check that they were provided and that they are reasonable before proceeding. A script that silently operates on an empty variable can delete files in unexpected directories or overwrite critical data.
Keep individual scripts focused on a single task. Rather than building one massive script that handles backups, deployments, monitoring, and log rotation, write separate scripts for each concern and compose them together. This makes each script easier to test, debug, and reuse in different contexts. Think of it as the Unix philosophy applied one layer up: each script should do one thing well.
Consider also the principle of idempotency$ idempotencyA property of operations where running the same operation multiple times produces the same result as running it once. In scripting: a script is idempotent if running it twice leaves the system in the same state as running it once. Critical for CI/CD pipelines and runbooks. -- a production-quality script should produce the same result whether it runs once or ten times. Use mkdir -p instead of mkdir. Check whether a user already exists before creating them. Compare configuration hashes before restarting a service. Idempotent scripts survive reruns, CI retries, and runbook mistakes without causing harm.
The underlying philosophy is widely shared among experienced shell scripters: scripts that fail loudly are far safer than scripts that fail silently, because a loud failure is immediately visible while a quiet one can go undetected for weeks. Combining set -eEuo pipefail with a trap cleanup function and meaningful error messages at every failure point is how you build that kind of audible, traceable behavior.
Finally, test your scripts in a safe environment before running them in production. Use bash -n script.sh to check for syntax errors without executing anything, and consider running destructive operations with echo prefixed to commands first (a technique sometimes called a "dry run") to verify that the right files and directories are being targeted.
DEPLOY_DIR="/opt/app" to DEPLOY_DIR="/opt/new-app" at the top of the script — but an old hardcoded reference to /opt/app is buried in one function. The script runs without error but deploys to the wrong location. How could the original author have prevented this?
readonly DEPLOY_DIR="/opt/app" — subsequent attempts to reassign them cause the script to abort with an error rather than silently taking effect. Third: run shellcheck on every commit — it catches unreferenced variables and structural issues that reviewers miss. The failure mode here is not a syntax error; it is a maintenance error caused by treating configuration as scattered literals rather than named, locked, auditable constants.
Install and run shellcheck on your scripts. ShellCheck is a static analysis tool written in Haskell by Vidar Holen (source: github.com/koalaman/shellcheck) that catches common Bash pitfalls -- unquoted variables, useless use of cat, incorrect test syntax, and dozens of other issues. It integrates with VS Code, Vim, Neovim, and most modern editors. Run it on every script you write and treat its warnings as errors, not suggestions. It is the single fastest way to improve your script quality and catch bugs before they reach production. You can paste any script directly at shellcheck.net for instant feedback without installing anything.
Security: What Beginners Get Wrong
Bash scripts interact directly with the operating system. That power comes with responsibility. Many beginner scripts -- and more than a few production scripts -- contain vulnerabilities that would not exist in equivalent Python or Go code, because the shell's convenience features are also its attack surface.
The injection problem. The most common Bash security flaw is passing user-controlled input to a command without proper quoting or sanitization. This is called command injection$ command injectionAn attack where user-supplied input is interpreted as shell commands rather than data. CISA and FBI named it a top software security bad practice in 2024. In Bash, the primary defenses are quoting all variables, whitelisting input with regex, and never using eval on untrusted input.CISA Alert (2024). If your script does something like eval "echo $USER_INPUT" and the input is ; rm -rf /, the results are catastrophic. Never use eval on untrusted input. Quote every variable. Use whitelists -- explicitly check that input matches an expected pattern before using it.
#!/bin/bash set -euo pipefail # Only allow alphanumeric environment names -- whitelist approach ENV="${1:-}" if [[ ! "$ENV" =~ ^[a-zA-Z0-9_-]+$ ]]; then echo "ERROR: Environment name must be provided and contain only letters, numbers, hyphens, or underscores." exit 1 fi # Safe to use $ENV now -- it only contains known-safe characters echo "Deploying to: $ENV"
staging. They type a crafted payload designed to be interpreted as shell syntax.read command stores the full string — including the semicolon and everything after it — into $USER_INPUT without complaint. No error. No warning.eval "echo $USER_INPUT". eval takes its arguments, concatenates them into a string, and hands that string back to Bash for a second round of parsing. The semicolon is now a command separator — just as if the attacker had typed two commands at the terminal.$ cat /etc/passwd > /tmp/leaked.txt # runs, writes passwd to /tmp
[[ "$ENV" =~ ^[a-zA-Z0-9_-]+$ ]] — rejects the payload at step 2 because a semicolon is not in the allowed character set. The input never reaches eval. Quoting alone is not sufficient when eval is in play; you need to gate what characters can enter the pipeline at all. The same whitelist principle blocks glob injection, path traversal, and newline injection simultaneously.
# Unquoted variable -- word splits on spaces rm -rf $USER_DIR # eval on user input -- catastrophic eval "process $USER_INPUT" # Predictable temp file -- symlink attack TMPFILE="/tmp/output.txt" # Hardcoded secret in script API_KEY="sk-abc123supersecret"
; rm -rf / deletes everything the script user can touch.# Quoted -- space-safe
rm -rf "$USER_DIR"
# Whitelist validate, never eval
[[ "$INPUT" =~ ^[a-zA-Z0-9_-]+$ ]] || exit 1
# Unpredictable temp file, auto-cleanup
TMPFILE=$(mktemp)
trap 'rm -f "$TMPFILE"' EXIT
# Secret from env var, not embedded
API_KEY="${API_KEY:?API_KEY must be set}"
Temporary file vulnerabilities. A script that creates temp files like /tmp/myscript_output.txt is vulnerable to symlink attacks and file clobbering. An attacker who can predict your temp file name can create a symlink before your script runs, redirecting your writes to an arbitrary location. Use mktemp$ mktempCreates a temporary file with a cryptographically unpredictable name in /tmp and prints the path. Prevents symlink attacks that exploit predictable filenames. Use mktemp -d for a temp directory.See: Security section, which generates a unique unpredictable name, and always clean up with trap:
#!/bin/bash set -euo pipefail # WRONG: predictable, world-readable, vulnerable to symlink attacks # TMPFILE="/tmp/myscript_output.txt" # CORRECT: unique names, owner-only permissions, single consolidated trap # Each trap call REPLACES the previous one -- never use two separate trap # EXIT registrations or the first cleanup target will be silently dropped. TMPFILE=$(mktemp) || { echo "ERROR: Could not create temp file"; exit 1; } WORKDIR=$(mktemp -d) trap 'rm -f "$TMPFILE"; rm -rf "$WORKDIR"' EXIT echo "Working in $WORKDIR"
Secrets in scripts. Never hardcode passwords, API keys, or tokens directly in a script file. Script files end up in version control, process listings (/proc/<pid>/cmdline), and log files. Instead, read secrets from environment variables, a secrets manager, or a file with restricted permissions (chmod 600) that is explicitly excluded from version control. Even environment variables can leak into subprocesses, so for highly sensitive values, consider reading from a file descriptor directly.
Least privilege. Run scripts with the minimum permissions required. Use sudo only for the specific commands that need it, rather than running the entire script as root. A bug in a script running as root can affect the whole system; a bug in a script running as a limited user is contained. The same script that runs fine in testing can silently delete system files in production if the execution context changes.
Option-terminator injection. Correct quoting prevents word-splitting but does not prevent option injection. If your script passes a user-supplied value as a positional argument to a command, an attacker can supply a value like -rf /important/path or --config=/etc/malicious.conf that the target command interprets as a flag rather than data. The fix is to insert -- between the last option and any user-controlled argument -- this signals to the command that everything following is a literal positional argument, not an option. For example, grep -- "$PATTERN" "$FILE" instead of grep "$PATTERN" "$FILE". This is one of the most commonly skipped defenses, even in scripts that otherwise quote correctly.
Lock configuration with readonly. Variables that hold paths, thresholds, or other configuration values that should never change during execution can be declared readonly. This prevents a downstream function or a sourced file from accidentally overwriting a value you depend on: readonly BACKUP_DIR="/mnt/backups". If anything later in the script tries to reassign BACKUP_DIR, Bash will exit with an error rather than silently continuing with a corrupted value. This is especially important for path variables used in destructive operations.
Prevent accidental overwrites with set -C. By default, Bash will silently overwrite an existing file when you redirect output to it with >. Adding set -C (also written set -o noclobber) causes Bash to refuse to overwrite an existing file via redirection, exiting with an error instead. This catches a class of accidental data-loss bugs where a misconfigured output path or a reused variable name causes a script to clobber a file it was never supposed to touch. Use >| to explicitly override noclobber when you actually do intend to overwrite.
Control the temp directory explicitly. mktemp uses the value of $TMPDIR when set, and falls back to /tmp otherwise. On a shared system, /tmp is world-writable and has been the scene of symlink and race-condition attacks for decades -- even with unpredictable filenames, the directory itself is a shared attack surface. If your script runs in a controlled environment where you can guarantee a non-shared directory (an operator-owned directory with restricted permissions, or a subdirectory created at script start with chmod 700), set TMPDIR explicitly before calling mktemp: TMPDIR=$(mktemp -d /var/run/myscript.XXXXXX); export TMPDIR. This narrows the attack surface to a directory only the script's user can read or write.
Audit your script's environment assumptions. Scripts inherit the full environment of the calling process, including any PATH, IFS, LD_PRELOAD, or other variables that a compromised parent could have manipulated. A defensive script resets the variables it depends on explicitly rather than assuming they are safe: set PATH to only the directories you need, reset IFS to its default if you rely on word-splitting behavior, and never assume that the environment your script runs in during testing matches the environment it will run in under cron, sudo, or a CI runner.
Command injection vulnerabilities -- the category that unquoted variables and eval misuse fall into -- have been a persistent threat for decades. On July 10, 2024, CISA and the FBI released a Secure by Design Alert specifically titled Eliminating OS Command Injection Vulnerabilities, issued in direct response to threat actor campaigns exploiting OS command injection flaws in network edge devices (CVE-2024-20399, CVE-2024-3400, CVE-2024-21887). The alert characterizes these vulnerabilities as entirely avoidable, noting that software manufacturers have continued shipping products with this flaw despite two decades of well-documented mitigations. In the companion Product Security Bad Practices guidance (updated January 2025), CISA and the FBI formally reclassified command injection as a product security bad practice, recommending that manufacturers prevent it by strictly allowing only alphanumeric characters and underscores when sanitizing user input -- the same whitelist pattern demonstrated in this guide. Bash scripting is one of the places where injection flaws are easiest to introduce and easiest to prevent. Quoting variables and avoiding eval on untrusted input are not just good practice -- they are the baseline defense against this entire class of attack. Sources: CISA Alert, July 10 2024; CISA/FBI Product Security Bad Practices v2, January 2025.
An attacker provides the input ; cat /etc/passwd to a script that runs eval "echo $USER_INPUT". What happens?
echo command and starts a new command. The shell executes echo (outputs nothing meaningful), then executes cat /etc/passwd -- reading the system user database. This is command injection via eval. The fix is to never pass user input to eval, and to validate all input with a regex whitelist before any command use.Why is /tmp/myscript_output.txt a security risk as a temp file name?
mktemp generates a cryptographically unpredictable name that prevents this.When Bash Is the Wrong Tool
Knowing when to use Bash also means knowing when to stop using it. Chet Ramey, the primary maintainer of Bash since 1990, has noted that the shell was designed as an orchestration layer -- a way to connect programs together -- not as a general-purpose programming language. When that boundary gets crossed, the results tend to be fragile. The MIT Student Information Processing Board's safe shell scripting guidance observes that when possible, using a higher-level language avoids an entire class of shell-specific pitfalls. Bash is not always the right answer, and recognizing the boundary is part of writing scripts professionally.
Bash handles orchestration well: running commands, piping output, checking exit codes, reading files line by line, and scheduling work. It handles complex data manipulation poorly. If you find yourself writing long chains of awk, sed, and cut to parse structured data, or building deeply nested conditional logic, or working with JSON, YAML, or XML -- Python for Linux system administration, Go, or even a purpose-built tool like jq will produce more readable, testable, and maintainable results.
A useful heuristic: if you cannot read the script back to yourself aloud as a sequence of actions and have it make sense, it is probably doing too much in the wrong language. Scripts that need proper data structures, unit tests, HTTP client libraries, or database connections belong in a language with those facilities built in. Scripts that need to run other programs, manage files, and respond to system events are exactly what Bash was designed for.
A common pattern in operations teams is to prototype automation in Bash and then rewrite in Python once the logic grows beyond a hundred lines or so. There is nothing wrong with that progression. The Bash version proves the concept and gets results quickly; the Python version provides the structure for long-term maintenance. Knowing that the rewrite is coming also changes how you write the Bash version -- you keep it simple, focused, and well-commented rather than building a fragile monolith that nobody wants to touch.
| dimension | use bash when... | switch to python (or go) when... |
|---|---|---|
| data format | plain text, line-oriented, CSV with cut/awk | JSON, YAML, XML, binary, database records |
| primary task | running other programs, piping output, managing files | transforming data structures, HTTP calls, parsing APIs |
| script length | under ~100 lines of clear logic | logic exceeds 100 lines or nesting depth exceeds 3 levels |
| error handling | exit codes and trap are sufficient | structured exceptions, retries, partial failure recovery needed |
| testing | manual test, bash -n syntax check, shellcheck | unit tests, mocking, CI coverage metrics required |
| portability | needs to run on any Linux server without dependencies | Python is already available or the environment is controlled |
| team readability | team is comfortable reading shell pipelines | team reads Python more fluently; script will be maintained long-term |
| read-aloud test | you can read the script aloud as a sequence of actions and it makes sense | reading it aloud produces chains of awk, sed, and cut that require decoding |
Wrapping Up
Bash scripting is not about learning a new programming language -- it is about giving structure and repeatability to the commands you already use every day. The barrier to entry is low because you are working with tools that are already installed on your system, and the payoff is immediate because every script you write eliminates a manual process that was costing you time and attention.
Start small. Pick a task you do repeatedly -- checking log files, restarting a service, generating a report -- and automate it. Once you have a working script, refine it. Add error handling. Add input validation. Add logging. Each improvement makes the script more robust and teaches you a new Bash concept in a practical context. Crucially, add security thinking from the start: validate inputs, use mktemp for temp files, quote every variable, and never embed secrets in the script body.
The patterns in this guide -- variables with parameter expansion defaults, conditionals, loops, functions with local scope, error handling with set -euo pipefail, trap-based cleanup, input validation with regex, and secure temp file handling -- cover the vast majority of what you will need for day-to-day automation. From here, you can explore more advanced topics like process substitution, associative arrays, named pipes, signal handling, and building interactive menus with select. But the foundation you have now is enough to start writing scripts that save you real time, catch their own mistakes, and do not create new security problems in the process.
How to Get Started with Bash Scripting
Step 1: Write your first script
Create a plain text file starting with #!/bin/bash, add your shell commands, then run chmod +x on the file to make it executable. Invoke it with ./scriptname.sh.
Step 2: Use variables and conditionals
Assign variables without spaces around the equals sign and always double-quote them when referencing. Use if/elif/else blocks with test operators to make decisions based on command output, file existence, or numeric comparisons.
Step 3: Add error handling and security
Add set -euo pipefail near the top of every script. Use trap to clean up temporary files on exit. Validate all user-supplied input with a regex whitelist before passing it to commands, and never embed secrets directly in the script file.
Frequently Asked Questions
What is Bash scripting?
Bash scripting is the practice of writing sequences of shell commands in a plain text file so that the Bash interpreter can execute them automatically. Scripts can handle variables, conditionals, loops, functions, and error handling, turning manual terminal workflows into repeatable, automated processes.
How do I make a Bash script executable?
Run chmod +x yourscript.sh to add the executable permission, then invoke it with ./yourscript.sh. The script must also start with a shebang line such as #!/bin/bash or the more portable #!/usr/bin/env bash so the operating system knows which interpreter to use.
What does set -euo pipefail do in a Bash script?
set -eEuo pipefail enables four safety mechanisms: -e causes the script to exit immediately when any command fails, -E (errtrace) ensures the ERR trap is inherited by functions and subshells so a failure inside a called function fires the trap correctly, -u treats unset variables as errors to prevent silent typo-based bugs, and -o pipefail makes a pipeline fail if any command in it fails rather than only the last one.
What is a shebang line in a Bash script?
A shebang line is the first line of a script, starting with #! followed by the path to the interpreter. For Bash, this is typically #!/bin/bash or the more portable #!/usr/bin/env bash. The shebang tells the operating system which program should execute the file. Without it, the system may use the wrong shell or refuse to run the file as an executable at all.
How do I prevent command injection in a Bash script?
The primary defenses are: always double-quote every variable reference ("$VAR" not $VAR), never pass user input to eval, validate all user-supplied input against a strict regex whitelist before using it in a command, and use mktemp for temporary files to avoid predictable names. CISA and the FBI identified command injection as a top software security bad practice in their July 2024 Secure by Design alert.
What is the difference between [ ] and [[ ]] in Bash?
Single brackets [ ] invoke the external test command and are POSIX-compatible but require careful quoting and do not support regex matching directly. Double brackets [[ ]] are a Bash built-in that support regex matching with =~, logical operators without extra escaping, and are generally safer and more expressive. Use [[ ]] in Bash scripts unless you specifically need POSIX portability for scripts that will run under /bin/sh.
When should I use Python instead of Bash?
Use Bash when your task is primarily about running commands, piping output, managing files, and scheduling work. Switch to Python when you need to parse structured data formats like JSON or YAML, build complex data structures, write unit tests, make HTTP requests, or when your script grows beyond roughly a hundred lines of tangled logic. A common pattern in operations teams is to prototype in Bash for speed and rewrite in Python for long-term maintainability.
Common Mistakes Reference Log
Every mistake below appears somewhere in this guide. This section aggregates them into one scannable reference you can return to when reviewing your own scripts or doing a code walkthrough with a teammate.
trap ... EXIT call replaces the previous one — if you create multiple temp resources, consolidate all cleanup into a single trap command or function.-E (errtrace) flag is particularly easy to omit -- without it, ERR traps are not inherited by functions, meaning a failure inside a called function bypasses the trap entirely. A failed backup, a failed git pull, or a failed curl can all be ignored. The script reports success while the work never happened.trap ... EXIT call silently replaces the previous one. If you register two separate traps, only the second runs. Any resource created before the second trap is never cleaned up on failure.source executes its argument as Bash code. A YAML file is not valid Bash — at best it throws a syntax error; at worst a crafted config file runs arbitrary commands with the script's permissions. Use a parser (yq, python3 -c, etc.) to extract values.-gt, -lt, -eq etc. are numeric operators; use == or != for string comparisons.Sources and Further Reading
The technical claims in this article draw on primary and authoritative sources. Key references are listed below for verification:
- GNU Bash Reference Manual -- the official, authoritative reference for all Bash behavior, maintained by Chet Ramey.
- GNU Bash home page (gnu.org) -- current release history and the official announcement of Bash 5.3 (July 3, 2025).
- ShellCheck on GitHub -- source code and documentation for Vidar Holen's static analysis tool. The online checker is at shellcheck.net.
- CISA/FBI: Eliminating OS Command Injection Vulnerabilities -- July 10, 2024. Primary source for the command injection security context in the Security section.
- CISA/FBI Product Security Bad Practices v2 -- January 2025. Updated guidance naming command injection as a formal product security bad practice, with recommended allowlist mitigations.
- Red Hat Command Line Heroes: Heroes in a Bash Shell -- primary source interviews with Brian Fox and Chet Ramey covering the origin of Bash.
- GNU Bash Manual: The Set Builtin -- authoritative documentation for
set -euo pipefailbehavior.