Currently Empty: $0.00
Legendary Ways Academy · Foundations
Bash Scripting, Automate What You’d Otherwise Repeat
Every deploy script, cron job, and CI pipeline step you’ll write early in a DevOps career is a Bash script. This is the guide that takes you from single commands to real automation.
Copy-paste scripts
Real deploy example
Error handling
The moment you find yourself running the same three or four commands in sequence more than twice, that’s the moment a Bash script should replace them. Bash scripting is the practice of writing those commands into a reusable file, the shell runs top to bottom, so what used to take five manual steps and a chance to mistype something becomes one reliable command. In DevOps specifically, Bash is everywhere: CI/CD pipeline steps are frequently just Bash commands, deploy scripts are Bash, cron jobs that rotate logs or clean up disk space are Bash, and Dockerfile RUN instructions execute in a shell that understands the same syntax.
This guide walks through the actual building blocks, variables, conditionals, loops, functions, and argument handling, then ties them together into a realistic deploy script, and covers the error-handling habits that separate a script you can trust in production from one that fails silently and leaves a mess. Along the way it also covers scheduling scripts with cron, catching mistakes automatically with shellcheck, and the specific pitfalls that trip up nearly every engineer’s early scripts, unquoted variables, missing error handling, and assumptions about the environment a script will actually run in.
None of this requires a programming background. Bash’s syntax is unusual compared to languages like Python or JavaScript, but the concepts, variables, conditionals, loops, are the same ones you’d encounter in any language, applied to the specific job of chaining shell commands together reliably.
The Building Blocks
Variables
Store and reuse values, from a version number to a file path, without repeating them throughout the script.
Conditionals
if/else logic that lets a script branch based on whether a command succeeded or a value matches.
Loops
for and while loops that repeat an action across a list of files, servers, or values.
Functions
Reusable blocks of logic you can call by name instead of copy-pasting the same lines repeatedly.
Anatomy of a Script
Every Bash script starts with a shebang line, #!/bin/bash, telling the system which interpreter should run the file. After that, it’s just the same commands you’d type interactively, executed in order. Making the file executable with chmod +x lets you run it directly as ./script.sh instead of needing to type bash script.sh every time.
bash
#!/bin/bash
echo "Starting deploy..."
cd /var/www/app
git pull origin main
npm install
echo "Deploy complete."
This script alone already saves real time: five commands, run reliably every time in the same order, instead of typed manually and occasionally run out of sequence. Save it as deploy.sh, run chmod +x deploy.sh, and it’s callable from anywhere as a single command.
Variables and User Input
Variables hold values you’ll reuse throughout a script. Assign with = (no spaces around it, a common early syntax error) and reference with a $ prefix. Scripts can also accept arguments from the command line, referenced as $1, $2, and so on, which is how a single script can be made reusable across environments instead of hardcoded for one.
bash
#!/bin/bash
ENVIRONMENT=$1
APP_DIR="/var/www/$ENVIRONMENT"
echo "Deploying to $ENVIRONMENT"
echo "Target directory: $APP_DIR"
Run as ./deploy.sh staging, and $1 captures “staging,” letting the same script deploy to staging or production depending on the argument passed, rather than maintaining two nearly identical scripts.
Conditionals
if statements let a script make decisions, most commonly checking whether a file exists, whether a variable matches an expected value, or whether a previous command succeeded. In Bash, a command’s exit code (0 for success, anything else for failure) is what conditionals actually test, which is a slightly different mental model than most programming languages.
bash
#!/bin/bash
if [ -f "package.json" ]; then
echo "Found package.json, installing dependencies..."
npm install
else
echo "No package.json found. Exiting."
exit 1
fi
-f tests whether a file exists; other common tests include -d for a directory, -z for an empty string, and -eq for numeric equality. exit 1 stops the script and returns a non-zero exit code, signaling failure to whatever called the script, a CI pipeline included, so it can react accordingly rather than silently continuing.
Loops
for loops repeat an action across a list, whether that’s a set of files, a list of servers, or a range of numbers. while loops repeat as long as a condition stays true, useful for retry logic like waiting for a service to become healthy.
bash
#!/bin/bash
for server in web-1 web-2 web-3; do
echo "Restarting $server..."
ssh "$server" "sudo systemctl restart app"
done
RETRIES=0
while ! curl -sf http://localhost:8080/health; do
echo "Waiting for service..."
sleep 5
RETRIES=$((RETRIES + 1))
if [ "$RETRIES" -gt 10 ]; then
echo "Service did not become healthy in time."
exit 1
fi
done
This second example is a real pattern used in deploy scripts everywhere: after restarting a service, poll a health check endpoint until it responds successfully, with a retry limit so the script fails loudly instead of hanging forever if something is actually broken.
Functions
Once a script grows past a handful of steps, functions keep it readable and avoid repeating logic. A function is defined once and called by name anywhere in the script, and can accept arguments the same way the script itself does.
bash
#!/bin/bash
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
log "Starting deploy"
git pull origin main
log "Pulled latest code"
npm install
log "Dependencies installed"
A small logging function like this, timestamping every step, is one of the highest-value additions to any real deploy script: when something fails at 3am, timestamped output makes it far easier to see exactly which step failed and how long each prior step took.
Error Handling: The Habit That Actually Matters
The single most important line in a production Bash script is set -e near the top, which causes the script to exit immediately if any command fails, rather than continuing past a broken step as if nothing went wrong. Without it, a failed git pull in a deploy script doesn’t stop the script, it just continues on to install dependencies and restart a service against whatever code happened to be there before, often silently shipping a broken or stale deploy.
bash
#!/bin/bash
set -euo pipefail
echo "Starting deploy..."
cd /var/www/app || exit 1
git pull origin main
npm install
npm run build
sudo systemctl restart app
echo "Deploy successful."
set -euo pipefail is the standard defensive header for production scripts: -e exits on any failed command, -u treats unset variables as an error instead of silently substituting an empty string, and -o pipefail makes a pipeline (commands joined with |) fail if any command in it fails, not just the last one. Adding this single line to every script you write is one of the highest-leverage habits in this entire guide.
A Realistic End-to-End Deploy Script
Putting the pieces above together, here’s a deploy script closer to what you’d actually run in production, combining logging, error handling, a health check retry loop, and a rollback path if the deploy fails partway through.
bash
#!/bin/bash
set -euo pipefail
APP_DIR="/var/www/app"
BACKUP_DIR="/var/backups/app-$(date +%s)"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
log "Backing up current release to $BACKUP_DIR"
cp -r "$APP_DIR" "$BACKUP_DIR"
log "Pulling latest code"
cd "$APP_DIR"
git pull origin main
log "Installing dependencies"
npm install --production
log "Restarting service"
sudo systemctl restart app
log "Waiting for health check"
RETRIES=0
until curl -sf http://localhost:8080/health; do
sleep 3
RETRIES=$((RETRIES + 1))
if [ "$RETRIES" -gt 10 ]; then
log "Health check failed. Rolling back."
rm -rf "$APP_DIR"
mv "$BACKUP_DIR" "$APP_DIR"
sudo systemctl restart app
exit 1
fi
done
log "Deploy successful"
rm -rf "$BACKUP_DIR"
Notice the shape of this: back up before changing anything, do the risky work, verify the result with a real health check, and automatically roll back to the known-good backup if verification fails. This exact pattern, backup, change, verify, rollback-on-failure, is the core structure of almost every reliable deployment mechanism, whether it’s a hand-written Bash script like this one or a managed platform’s built-in deploy strategy.
Common Pitfalls to Avoid
A handful of mistakes show up constantly in early Bash scripts. Unquoted variables are the most common: rm -rf $DIR becomes dangerous if $DIR is ever empty or contains spaces, since Bash will word-split it unexpectedly; writing rm -rf "$DIR" with quotes avoids this entire class of bug. Comparing strings with a single = inside [ ] works, but forgetting the spaces around it, like [ "$a"="$b" ], silently fails rather than throwing a clear error. And assuming a script’s current working directory is always what you expect is a frequent source of “works on my machine” bugs; scripts run by cron or CI often start from an unexpected directory, so use absolute paths or cd explicitly rather than relying on relative paths.
bash
# Risky: word-splitting if DIR is unset or has spaces
rm -rf $DIR
# Safe: quoted, and defaults to a safe no-op if unset
rm -rf "${DIR:?DIR is not set}"
The ${DIR:?message} syntax is a genuinely useful defensive pattern: if DIR is unset or empty, the script prints the given message and exits immediately, rather than silently running a destructive command against an unintended path (or worse, the root of the filesystem).
Scheduling Scripts With Cron
Many Bash scripts in a DevOps context don’t run on demand, they run on a schedule: nightly backups, log rotation, certificate renewal checks. cron is the standard Linux scheduler, configured through a crontab file with a five-field time expression (minute, hour, day of month, month, day of week) followed by the command to run.
bash
# Edit the current user's crontab
crontab -e
# Run a backup script every day at 2:30am
30 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
# Run a cleanup script every Sunday at midnight
0 0 * * 0 /opt/scripts/cleanup.sh
Redirecting output with >> /var/log/backup.log 2>&1 is worth doing on every cron job by default: it captures both standard output and errors to a log file, since a failed cron job with no output redirection fails completely silently, and you’ll only discover the problem once its absence causes a bigger issue days or weeks later.
How This Connects to the Rest of DevOps
Bash scripting is the connective tissue between nearly every other DevOps topic. CI/CD pipeline steps, covered in our CI/CD pipelines guide, are frequently nothing more than a sequence of Bash commands run by the CI platform. Dockerfiles run shell commands during the build process. Terraform provisioning scripts and Kubernetes deployment automation both frequently shell out to Bash for glue logic that’s awkward to express in their native configuration languages. If Linux and the command line are the foundation, Bash scripting is the layer that turns individual commands into dependable, repeatable systems.
Even teams that eventually adopt more sophisticated tooling, a full CI/CD platform, Terraform for infrastructure, Ansible for configuration, still rely on Bash underneath most of it: a CI job’s “run” step is Bash, a Terraform provisioner frequently calls out to a shell script, and an Ansible task can drop straight into raw shell commands when a built-in module doesn’t cover what’s needed. Skipping Bash to jump straight to more advanced tools tends to backfire; those tools assume the fluency this guide covers.
Frequently Asked Questions
Should I learn Bash or Python first for DevOps automation?
Bash first, for anything that’s mostly running and chaining existing commands (which is most early DevOps automation). Python becomes more valuable once you need real data structures, error handling beyond exit codes, or logic too complex to comfortably express in shell syntax.
Why did my script work when I ran it manually but fail when run by cron or CI?
Almost always an environment difference: cron and CI runners often have a minimal PATH and no shell profile loaded, so commands or variables you assumed were available aren’t. Set the full PATH explicitly and avoid relying on interactive shell configuration inside scripts meant to run unattended.
What’s the difference between sh and bash?
sh is a more minimal POSIX shell standard; bash is a specific, more feature-rich shell that’s a superset of POSIX sh. Scripts using Bash-specific syntax (like arrays or [[ ]] tests) need the #!/bin/bash shebang specifically, not #!/bin/sh.
How do I debug a script that isn’t doing what I expect?
Run it with bash -x script.sh, which prints every command as it executes along with variable substitutions, making it much easier to see exactly where behavior diverges from what you expected.
Are there tools to check a script for common mistakes automatically?
Yes, shellcheck is a widely used static analysis tool that flags unquoted variables, common syntax pitfalls, and portability issues before you ever run the script. It’s worth installing and running against any script before it goes into production use.
Linting Scripts Before They Ship
Most of the pitfalls covered above, unquoted variables, unsafe defaults, portability issues between sh and bash, can be caught automatically before a script ever runs, using shellcheck. It’s a static analysis tool built specifically for shell scripts, and running it as a habit (or as a step in CI before a script gets used in a real pipeline) catches an entire category of bugs before they become a 2am incident.
bash
# Install (Debian/Ubuntu)
sudo apt install shellcheck
# Check a script
shellcheck deploy.sh
# Example output:
# In deploy.sh line 12:
# rm -rf $DIR
# ^-- SC2115 (info): Use "${DIR:?}" to ensure this never expands to / or empty
Many teams wire shellcheck directly into their CI pipeline so any script committed to the repository is automatically checked on every pull request, the same way a linter would check application code. Given how cheap it is to run and how expensive a silent scripting bug in a production deploy can be, this is one of the highest-value, lowest-effort additions to a team’s DevOps tooling.
If you’re building this skill specifically for a DevOps role, prioritize genuine comfort with the deploy-script pattern shown above (logging, error handling, health checks, rollback) over breadth of Bash trivia. Interviewers and hiring managers care far more about whether you instinctively reach for set -euo pipefail and a real health check than whether you can recite every string manipulation operator Bash supports. Build a handful of small, real scripts, a backup script, a deploy script, a log-cleanup script, and you’ll have covered the patterns that come up constantly in actual DevOps work.
Related reading: continue to Git and GitHub, see CI/CD pipelines for how scripts fit into automated pipelines, or return to the full topics overview.




