Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Bash Scripting for DevOps: Practical Patterns

By Kokil Thapa | Last reviewed: September 2026

Bash Scripting for DevOps: Practical Patterns still matter on real servers even when Kubernetes and Terraform get the headlines. Cron jobs, deploy hooks, backup wrappers, and CI preflight checks run as shell on Ubuntu boxes every night. If the script lacks strict error handling, you get silent partial deploys and databases restored to the wrong path. This guide collects patterns I use on production Linux system administration work—Deployer releases, GitLab CI runners, and one-off server fixes—so your scripts fail loudly, log clearly, and stay readable six months later.

What is Bash Scripting for DevOps and why does it still matter in 2026?

DevOps Bash scripts glue together tools that do not share a single API. Your Laravel app needs php artisan migrate, PHP-FPM reload, and Redis cache flush after a symlink swap. Ansible and Terraform cover much of that, but someone still writes the wrapper that runs when the pipeline SSHes into the box.

Bash remains the lowest-friction option on any Linux host. No runtime install, no container pull, no agent beyond what support and maintenance teams already manage. On sister sites I maintain with Deployer 7 and GitLab CI, the remote commands are often ten to forty lines of shell embedded in PHP deploy recipes or standalone scripts in /usr/local/bin.

The goal is not clever one-liners. The goal is predictable automation that the next engineer can audit at 2 a.m. during an incident. That aligns with how I approach the DevOps roadmap for 2026: boring infrastructure, explicit failure modes, and scripts that print what they did.

Bash in the DevOps StackGitLab CIRunner shellDeploy HookSSH remoteCron JobsBackupsAd-hocFix scriptsBash Script LayerGlue: apt, systemctl, mysqldump, artisan, rsyncUbuntu 22/24Apache + PHP-FPMMySQL 9.7Redis 8.10Laravel 13App releases
Bash Scripting for DevOps: Practical Patterns sit between CI runners, cron, and the Linux services your application depends on.

Compare Bash to heavier alternatives when choosing a tool for a task:

ApproachBest forTrade-off
Bash scriptSSH deploy steps, backups, log rotation, quick glueEasy to write unsafe code without discipline
Ansible playbooksMulti-host config drift, idempotent packagesNeeds inventory and Python on targets
Terraform + CICloud resources with stateWrong fit for local systemctl reload
Python / PHP CLIComplex logic, APIs, data transformsExtra dependencies on minimal servers

Most mature teams use all four. Bash wins when the script must run on a bare EC2 instance with nothing installed beyond what hosting and domain setup already provides.

How do you write a production-safe Bash script header?

Every DevOps Bash file should open with a predictable header. This single block prevents half the production surprises I have debugged after a deploy.

The strict mode block

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly SCRIPT_NAME="${0##*/}"

log() { printf '[%s] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*" >&2"; }
die() { log "ERROR: $*"; exit 1; }

trap 'die "Failed at line $LINENO: $BASH_COMMAND"' ERR

Here is what each line buys you:

  • set -e — exit on the first command that returns non-zero.
  • set -u — treat unset variables as errors.
  • set -o pipefail — a pipeline fails if any stage fails, not only the last.
  • IFS — sane word splitting; avoids bugs with filenames containing spaces.
  • trap ERR — prints the failing line number before exit.

The GNU Bash manual documents these options in the "The Set Builtin" section. Read it once and you will stop guessing why a pipeline swallowed an error. For deeper shell fundamentals, see the Ubuntu shell scripting tutorial on this site.

Arguments and environment

Parse flags explicitly instead of relying on positional magic:

usage() {
  cat <<EOF
Usage: ${SCRIPT_NAME} [--dry-run] --env staging|production

  --dry-run   Print commands without executing
  --env       Target environment (required)
EOF
}

ENV=""
DRY_RUN=0

while [[ $# -gt 0 ]]; do
  case "$1" in
    --dry-run) DRY_RUN=1; shift ;;
    --env)     ENV="${2:-}"; shift 2 ;;
    -h|--help) usage; exit 0 ;;
    *) die "Unknown argument: $1" ;;
  esac
done

[[ -n "$ENV" ]] || { usage; die "--env is required"; }

Validate early. A script that runs for twenty minutes before discovering a missing DB_PASSWORD wastes everyone's time. I often cross-check env var names against a regex tester when parsing config files that mix shell and dotenv syntax.

How should you handle errors and logging in DevOps Bash scripts?

Silent success is fine. Silent failure is how backups stop running for three weeks. Structured logging turns Bash from a black box into an audit trail.

Log to stderr, data to stdout

Unix convention: stdout carries data other commands consume; stderr carries human diagnostics. Pipelines stay composable.

run() {
  if [[ "$DRY_RUN" -eq 1 ]]; then
    log "DRY-RUN: $*"
    return 0
  fi
  log "RUN: $*"
  "$@"
}

backup_mysql() {
  local dump_file="/var/backups/app-$(date +%F).sql.gz"
  run mysqldump --single-transaction "$DB_NAME" | gzip > "$dump_file"
  log "Backup written: $dump_file"
}

The run helper gives you dry-run support for free. Test the script with --dry-run inside CI before it touches production paths.

Error Handling Flowset -euo pipefailValidate inputsrun step()OK?trap ERR firesLog line + commandLog successExit 0non-zerozeroIncident signal: stderr timestamp + non-zero exitCI fails stage · cron mails root · pager fires
Production Bash scripts should fail fast, log the failing command, and return explicit exit codes to CI and cron.

Exit codes that CI understands

Define meaningful codes at the top of the script:

readonly EXIT_OK=0
readonly EXIT_USAGE=1
readonly EXIT_PREFLIGHT=2
readonly EXIT_DEPLOY=3

preflight() {
  command -v php >/dev/null || die "php not found"
  [[ -d "$RELEASE_PATH" ]] || die "release path missing"
}

main() {
  preflight || exit "$EXIT_PREFLIGHT"
  deploy_app || exit "$EXIT_DEPLOY"
}

main "$@"

GitLab CI and GitHub Actions surface exit codes in the job summary. When a deploy fails with code 3, you know where to look without reading four hundred lines of log output. This pairs well with an incident response playbook that maps codes to runbooks.

When you must allow failure

Sometimes a non-zero result is acceptable—checking whether a service exists, for example. Never turn off set -e globally. Use a narrow pattern:

if ! systemctl is-active --quiet php8.3-fpm; then
  log "php8.3-fpm not active; attempting start"
  run systemctl start php8.3-fpm
fi

# Or for a single command:
systemctl is-active nginx || true

The || true suffix is explicit. Future readers know the failure was intentional. Hidden failures are what bite you during a PHP 8.3 to 8.5 upgrade on a busy server.

What Bash patterns work best for deployment and CI/CD automation?

Deployment scripts should be idempotent. Running the same release twice should not corrupt state. These patterns show up in Deployer hooks and standalone release scripts on projects like Notary Kathmandu and other sister sites on shared EC2 infrastructure.

RELEASES_DIR="/var/www/app/releases"
CURRENT_LINK="/var/www/app/current"
KEEP_RELEASES=5

deploy_release() {
  local release_id
  release_id="$(date +%Y%m%d%H%M%S)"
  local release_path="${RELEASES_DIR}/${release_id}"

  run mkdir -p "$release_path"
  run rsync -a --delete "${CI_ARTIFACT_PATH}/" "$release_path/"

  run ln -sfn "$release_path" "$CURRENT_LINK"

  ls -1dt "${RELEASES_DIR}"/* | tail -n +$((KEEP_RELEASES + 1)) | while read -r old; do
    run rm -rf "$old"
  done
}

ln -sfn atomically swaps the current symlink. Users mid-request either hit the old or new release; they do not hit a half-written tree. Prune old releases so disk usage stays predictable—full disks break MySQL and logs before deploy does.

Post-deploy Laravel steps

On Laravel 13 apps with PHP 8.3 or higher, a typical remote hook looks like this:

cd "$CURRENT_LINK" || die "cannot cd to current"

run php artisan down --retry=60 || true
run php artisan migrate --force
run php artisan config:cache
run php artisan route:cache
run php artisan view:cache
run php artisan queue:restart

run systemctl reload php8.3-fpm
run php artisan up

Put maintenance mode behind a flag if you need zero-downtime deploys with multiple nodes. Single-server setups often accept a short retry window. Match PHP-FPM version strings to what is actually installed—php8.4-fpm and php8.3-fpm coexist on many of my Ubuntu 24 servers.

Deploy Script Sequencegit pushCI buildSSH + bashSymlinkReloadRemote: rsync artifact → migrate → cache → queue:restartShared (persistent).env · storage/uploads · logsnever deletedRelease dirstimestampedcurrent → symlinkkeep last N
Zero-downtime deploy Bash patterns separate immutable release directories from shared persistent paths.

Backup wrapper with retention

Nightly cron is still Bash for many teams. A pattern I use on production databases:

RETENTION_DAYS=14
BACKUP_ROOT="/var/backups/mysql"

mkdir -p "$BACKUP_ROOT"
FILE="${BACKUP_ROOT}/db-$(date +%F-%H%M).sql.gz"

mysqldump --single-transaction --routines "$DB_NAME" | gzip > "$FILE"
find "$BACKUP_ROOT" -name 'db-*.sql.gz' -mtime +"$RETENTION_DAYS" -delete

Test restores monthly. A backup script that never restores is wishful thinking. Store credentials in root-only files under /root/.my.cnf, not inline in the script repository. Generate strong passphrases with a password generator and rotate them on the same schedule as SSH keys.

CI integration sketch

GitLab CI can call your script directly. Keep the YAML thin; put logic in versioned shell:

deploy_production:
  stage: deploy
  script:
    - bash scripts/deploy.sh --env production
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

See the full pipeline context in the Azure DevOps YAML pipelines guide—the stage names differ, but the Bash contract is identical. For pitfalls that bite teams new to shell in CI, read Bash scripting patterns and pitfalls.

How do you debug and test Bash scripts before they hit production?

ShellCheck is the first gate. Install it on your laptop and in CI. It catches quoting bugs, unreachable code, and unquoted variables that explode when a filename contains a space.

shellcheck scripts/deploy.sh
shellcheck --severity=warning scripts/*.sh

The ShellCheck wiki explains each error code with fixes. Treat warnings as build failures for deploy scripts; relax only for legacy one-offs you plan to retire.

Trace mode for staging

#!/usr/bin/env bash
set -euo pipefail
[[ "${DEBUG:-0}" == "1" ]] && set -x

Run with DEBUG=1 bash deploy.sh --env staging. You see every expansion before it executes. Turn trace off in production logs unless you enjoy leaking env var values into Splunk.

Bats or plain assert loops

For reusable functions, add minimal tests:

test_equals() {
  local expected="$1" actual="$2"
  [[ "$expected" == "$actual" ]] || die "expected '$expected', got '$actual'"
}

result="$(basename "/var/www/app/current")"
test_equals "current" "$result"

Full Bats frameworks help larger teams. Solo maintainers often run a scripts/test.sh in CI before the deploy stage. That matches how I validate hooks on Adventure Third Pole Trek booking infrastructure before peak season traffic.

  1. Run ShellCheck on every push.
  2. Execute against a staging VM with --dry-run first.
  3. Execute for real on staging with production-like data volume.
  4. Promote the same script hash to production—no last-minute edits on the server.

What are common Bash scripting mistakes that break production systems?

Knowing patterns is half the job. Avoiding anti-patterns keeps you out of pager duty.

Mistakes vs Safe PatternsAnti-patternSafe patternrm -rf $DIR/* unquotedrm -rf -- "${DIR:?}"/*curl | bash installersVendor checksum + aptSecrets in git scripts/root creds + CI varsset +e everywhereNarrow || true blocksEditing live server onlyScripts in git + review
Bash Scripting for DevOps: Practical Patterns replace fragile shortcuts with quoted paths, version control, and explicit error handling.
  • Unquoted variables$file breaks on spaces; use "$file" always.
  • Parsing ls — use globs or find with -print0 and read -d ''.
  • Date arithmetic without UTC — Nepal runs UTC+5:45; cron on UTC servers needs conscious timezone handling for BS-calendar jobs. Use the Nepali date converter in app code, not in shell date hacks.
  • Stale paths after deploy — cron entries pointing at old release folders are a classic. Symlink current/bin/maintain.sh or use absolute paths under /usr/local/bin.
  • Missing lock files — concurrent cron overlap doubles backups or migrations. Use flock:
(
  flock -n 9 || exit 0
  /usr/local/bin/backup-mysql.sh
) 9>/var/lock/backup-mysql.lock

Reference the official GNU Bash manual when unsure about expansion order. For interview prep on the Linux side of these scripts, see Linux interview questions for DevOps.

Key Takeaways

  • Start every DevOps Bash file with set -euo pipefail, a trap on ERR, and logging to stderr.
  • Wrap commands in a run helper that supports --dry-run for safe CI and staging tests.
  • Keep deploy scripts idempotent: atomic symlinks, retained release count, shared storage outside releases.
  • Run ShellCheck in CI; never edit production-only scripts without committing them to git first.
  • Use flock, meaningful exit codes, and narrow || true only where failure is expected.
  • Pair Bash glue with Ansible or Terraform for work that spans many hosts or cloud APIs.

People Also Ask

Is Bash still worth learning for DevOps in 2026?

Yes. Containers and IaC did not remove the need to run commands on hosts. SSH sessions, cron, CI runners, and emergency fixes still default to shell. You will read more Bash than you write, but writing it well saves hours when deploys fail.

What is the difference between Bash and sh for DevOps scripts?

/bin/sh on Ubuntu is often dash, which lacks Bash arrays, [[ ]], and source niceties. Use #!/usr/bin/env bash when you need those features. Use POSIX sh only when portability to minimal Alpine images matters more than readability.

How do I pass secrets to Bash scripts safely?

Never commit secrets. Use CI masked variables, root-only files on the server, or secret managers your platform provides. Export them in the CI job environment and reference by name inside the script. Log command names, not values.

Can Bash replace Ansible or Python for deployment?

Bash replaces them only on single-server or very small fleets. Once you manage permissions, packages, and config across dozens of VMs, Ansible or similar tools pay for themselves. Bash remains the right tool inside those tools' remote execution blocks.

Ship automation you can trust

Bash Scripting for DevOps: Practical Patterns is not about longer scripts. It is about scripts that fail in obvious ways, log what happened, and live in git beside the app they deploy. Start with strict headers on your next cron job, add ShellCheck to CI, and dry-run before touching production. If you want help hardening deploy hooks on Ubuntu, Laravel releases, or GitLab pipelines, contact us or explore custom software development and web development services. For background on the person behind these patterns, visit about me or browse the portfolio of production systems maintained with the same Deployer and CI workflow described here.

Frequently Asked Questions

Strict headers with set -euo pipefail, structured logging to stderr, idempotent deploy steps, explicit exit codes, and small reusable functions—tested with ShellCheck and --dry-run before cron or deploy hooks touch production.

Yes. Containers and IaC did not remove the need to run commands on hosts. SSH sessions, cron, CI runners, and emergency fixes still default to shell on bare Linux boxes.

set -e exits on the first non-zero command. set -u treats unset variables as errors. pipefail makes a pipeline fail if any stage fails, not only the last command.

Start with #!/usr/bin/env bash, then set -euo pipefail, IFS=$'\n\t', readonly SCRIPT_DIR and SCRIPT_NAME, log() and die() helpers writing to stderr, and trap 'die "Failed at line $LINENO: $BASH_COMMAND"' ERR. This block prevents silent partial deploys and makes failures auditable at 2 a.m. Parse flags explicitly with a usage() function and validate required arguments like --env before any long-running work begins.

Log diagnostics to stderr and reserve stdout for data other commands consume. Wrap commands in a run helper that logs RUN or DRY-RUN and supports --dry-run for CI staging tests. Define meaningful exit codes at the top—EXIT_OK, EXIT_USAGE, EXIT_PREFLIGHT, EXIT_DEPLOY—so GitLab CI job summaries point you to the failing stage. Never disable set -e globally; use narrow patterns like if ! command or command || true only where failure is intentional and documented.

On Ubuntu, /bin/sh is often dash, which lacks Bash arrays, [[ ]] tests, and source. Use #!/usr/bin/env bash when you need those features for deploy hooks, argument parsing, and trap ERR handling. Reserve POSIX sh only when portability to minimal Alpine images matters more than readability. Most production DevOps glue on Ubuntu EC2 instances assumes Bash because cron, Deployer remote commands, and GitLab CI runners already invoke it without extra runtime installs.

Never commit secrets to git. Use CI masked variables, root-only credential files on the server such as /root/.my.cnf for MySQL, or your platform secret manager. Export variables in the CI job environment and reference them by name inside the script. Log command names through your run helper, never variable values. Turn DEBUG trace mode off in production unless you want database passwords appearing in log aggregators like Splunk.

Bash replaces them only on single-server or very small fleets where nothing beyond standard hosting tools is installed. Ansible suits multi-host config drift and idempotent packages; Terraform plus CI suits cloud resources with state. Python or PHP CLI fits complex API logic and data transforms. Mature teams use all four. Bash wins for SSH deploy steps, backup wrappers, log rotation, and quick glue on bare EC2 instances—then sits inside Ansible or Terraform remote execution blocks for larger fleets.

Separate immutable release directories under /var/www/app/releases from a CURRENT_LINK symlink swapped atomically with ln -sfn. Rsync artifacts into a timestamped release folder, prune old releases to KEEP_RELEASES count, and run post-deploy Laravel steps from the current symlink: artisan migrate --force, config/route/view cache, queue:restart, and systemctl reload php8.3-fpm. Match PHP-FPM service names to what is actually installed—php8.3-fpm and php8.4-fpm often coexist on Ubuntu 24 servers. Keep GitLab CI YAML thin and put logic in versioned scripts/deploy.sh.

Running the same release twice must not corrupt state. Create a new timestamped release directory each deploy, rsync with --delete into that folder, then atomically repoint the current symlink with ln -sfn so users never hit a half-written tree. Prune releases beyond KEEP_RELEASES so disk usage stays predictable—full disks break MySQL before deploy does. Shared persistent paths like storage and .env live outside release folders. Post-deploy artisan commands should be safe to rerun; maintenance mode with --retry=60 can be gated behind a flag for multi-node zero-downtime setups.

Run ShellCheck on every push—treat warnings as build failures for deploy scripts. Install it locally and in CI with shellcheck scripts/deploy.sh. Test with --dry-run inside CI before production paths are touched. On staging, run DEBUG=1 bash deploy.sh --env staging to enable set -x trace mode and see every expansion—never leave trace on in production. Add minimal assert helpers or a scripts/test.sh stage before deploy. Promote the same committed script hash to production; never edit scripts only on the server.

Unquoted variables break on filenames with spaces—always use "$file". Parsing ls output instead of globs or find -print0 causes subtle bugs. Date arithmetic without UTC awareness breaks cron on UTC servers when Nepal runs UTC+5:45. Stale cron paths pointing at old release folders instead of a current symlink cause scripts to vanish after deploy. Missing lock files let concurrent cron overlap double backups or migrations—wrap jobs with flock -n on a lock file under /var/lock. Hidden failures from pipelines without pipefail are classic post-upgrade surprises.

Keep the pipeline YAML thin and call a versioned shell script from the repository. A typical production job runs bash scripts/deploy.sh --env production on the main branch deploy stage. The Bash contract is identical whether you use GitLab CI or Azure DevOps YAML—only stage names differ. The script returns explicit exit codes so the job summary shows EXIT_PREFLIGHT versus EXIT_DEPLOY failures without reading hundreds of log lines. Pair this with Deployer 7 remote hooks or standalone scripts in /usr/local/bin on shared EC2 infrastructure.

Use flock whenever cron might overlap itself—nightly backups and migrations are the usual cases. Wrap the script invocation so a second run exits immediately if the lock is held: flock -n 9 || exit 0 around your backup command with the file descriptor pointing at /var/lock/backup-mysql.lock. Without this, concurrent runs double mysqldump load, fill disk faster, or race migrations. flock is lightweight, needs no extra packages on Ubuntu, and is clearer than pid-file hacks that stale after crashes.

Set RETENTION_DAYS and BACKUP_ROOT such as /var/backups/mysql, mkdir -p the root, then mysqldump --single-transaction --routines into a dated gzip file. Delete files older than retention with find ... -mtime +"$RETENTION_DAYS" -delete. Store MySQL credentials in a root-only /root/.my.cnf file, not inline in the repository. Test restores monthly—a backup script that never restores is wishful thinking. Run the job from cron through a flock lock and log each dump path through your structured log helper so silent failures are visible within one cron cycle.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: