Bash in CI/CD Pipelines: Writing Reliable Pipeline Scripts
Write reusable, portable Bash scripts for GitHub Actions, GitLab CI, and Jenkins. Covers environment variables, secret handling, idempotent deploys, and pipeline debugging. Lesson 9 of the Linux & Bash Scripting course.

Shell scripts are the glue in CI/CD pipelines. They build artefacts, run tests, push images, apply migrations, and restart services. Scripts that work locally but fail in pipelines — or that fail silently and produce incorrect deployments — are one of the most common sources of production incidents. This lesson covers the patterns that make pipeline scripts reliable across environments.
Previous: Lesson 8 — Cron, systemd Timers, and Task Scheduling
Pipeline Environment Differences
CI runners differ from developer machines in ways that cause scripts to break:
| Characteristic | Developer Machine | CI Runner |
|---|---|---|
| Shell | Interactive, sourced .bashrc | Non-interactive, minimal env |
| Working directory | Arbitrary | Repository root (usually) |
| Credentials | SSH agent, keychain | Injected secrets / env vars |
| Installed tools | Whatever the dev has | Only what the image provides |
| User | Named user with home dir | Often root or runner |
| Exit on error | Not set | Should be set — often isn't |
The fix is to write scripts that are self-contained: they set their own PATH, validate their own dependencies, and never rely on sourced configuration from outside the repo.
Pipeline Script Template
#!/usr/bin/env bash
# =============================================================================
# deploy.sh — Idempotent application deployment
# Usage: ./deploy.sh <environment> <image_tag>
# =============================================================================
set -euo pipefail
# ---------- Validate inputs ----------
readonly ENVIRONMENT="${1:?'Usage: deploy.sh <environment> <image_tag>'}"
readonly IMAGE_TAG="${2:?'Usage: deploy.sh <environment> <image_tag>'}"
# ---------- Validate required environment variables ----------
: "${KUBECONFIG:?'KUBECONFIG must be set'}"
: "${DOCKER_REGISTRY:?'DOCKER_REGISTRY must be set'}"
# ---------- Validate tools ----------
for cmd in kubectl docker jq; do
if ! command -v "$cmd" &>/dev/null; then
echo "ERROR: Required command not found: $cmd" >&2
exit 1
fi
done
# ---------- Logging ----------
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
# ---------- Main ----------
log "Deploying ${DOCKER_REGISTRY}/myapp:${IMAGE_TAG} to ${ENVIRONMENT}"
kubectl set image deployment/myapp \
app="${DOCKER_REGISTRY}/myapp:${IMAGE_TAG}" \
--namespace="${ENVIRONMENT}"
kubectl rollout status deployment/myapp \
--namespace="${ENVIRONMENT}" \
--timeout=300s
log "Deployment complete"GitHub Actions Integration
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Build and push image
run: |
docker build -t ${{ secrets.REGISTRY }}/myapp:${{ github.sha }} .
docker push ${{ secrets.REGISTRY }}/myapp:${{ github.sha }}
- name: Deploy to production
env:
KUBECONFIG: ${{ secrets.KUBECONFIG }}
DOCKER_REGISTRY: ${{ secrets.REGISTRY }}
run: ./scripts/deploy.sh production ${{ github.sha }}GitHub Actions Environment Variables
In GitHub Actions, all secrets are masked in logs. Access them via ${{ secrets.NAME }} in YAML or as environment variables in scripts:
# In a run: block, secrets injected as env vars
echo "Deploying to $ENVIRONMENT"
echo "Registry: $DOCKER_REGISTRY"
# Never echo a secret directly — it will be masked but is bad practiceGitLab CI Integration
# .gitlab-ci.yml
stages:
- build
- deploy
variables:
IMAGE: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
build:
stage: build
script:
- docker build -t "$IMAGE" .
- docker push "$IMAGE"
deploy_production:
stage: deploy
environment: production
when: manual
script:
- DOCKER_REGISTRY="$CI_REGISTRY_IMAGE" ./scripts/deploy.sh production "$CI_COMMIT_SHA"
only:
- mainHandling Secrets Safely in Scripts
Never hardcode secrets in scripts. The correct patterns:
# Pattern 1: Environment variable (injected by CI, not stored in repo)
DB_PASSWORD="${DB_PASSWORD:?'DB_PASSWORD must be set as an environment variable'}"
# Pattern 2: Read from a file (mounted secret in Kubernetes/Docker)
DB_PASSWORD="$(cat /run/secrets/db_password)"
# Pattern 3: Fetch from a secrets manager at runtime
DB_PASSWORD="$(aws ssm get-parameter \
--name "/myapp/production/db_password" \
--with-decryption \
--query 'Parameter.Value' \
--output text)"
# Never do this:
DB_PASSWORD="mysecretpassword" # Hardcoded in script
DB_PASSWORD="$(cat .env | grep DB_PASSWORD | cut -d= -f2)" # .env in repoMasking Secrets in Script Output
# Prevent accidental secret leakage in debug output
set +x # Disable xtrace before handling secrets
db_password="$(get_secret 'db_password')"
set -x # Re-enable after
# Or use a subshell to limit scope
(
set +x
export DB_PASSWORD="$(get_secret 'db_password')"
run_migrations
)Writing Idempotent Deploy Scripts
An idempotent deploy script produces the same result regardless of how many times it runs. This is critical for pipelines that may retry on failure:
#!/usr/bin/env bash
set -euo pipefail
readonly APP="myapp"
readonly NAMESPACE="${1:?}"
readonly IMAGE_TAG="${2:?}"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
# Check if this exact image tag is already deployed
current_tag=$(kubectl get deployment "$APP" \
-n "$NAMESPACE" \
-o jsonpath='{.spec.template.spec.containers[0].image}' \
2>/dev/null | awk -F: '{print $2}' || echo "")
if [[ "$current_tag" == "$IMAGE_TAG" ]]; then
log "Image ${IMAGE_TAG} is already deployed to ${NAMESPACE}. Nothing to do."
exit 0
fi
log "Updating ${APP} in ${NAMESPACE}: ${current_tag:-none} → ${IMAGE_TAG}"
kubectl set image "deployment/${APP}" \
"${APP}=${DOCKER_REGISTRY}/${APP}:${IMAGE_TAG}" \
-n "$NAMESPACE"
kubectl rollout status "deployment/${APP}" \
-n "$NAMESPACE" \
--timeout=300s
log "Rollout complete"Database Migrations in Pipelines
#!/usr/bin/env bash
set -euo pipefail
run_migrations() {
local environment="$1"
log "Running database migrations for ${environment}"
# Apply migrations (example: Flyway)
flyway \
-url="jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}" \
-user="${DB_USER}" \
-password="${DB_PASSWORD}" \
migrate
log "Migrations complete"
}
rollback_migration() {
log "Rolling back last migration"
flyway repair
}
trap rollback_migration ERR
run_migrations "$ENVIRONMENT"Pipeline Debugging Techniques
# Enable xtrace to print every command before it runs
set -x
# Disable after the noisy section
set +x
# Print environment variables (safely — redact secrets)
env | grep -v 'PASSWORD\|SECRET\|TOKEN\|KEY' | sort
# Add timing to slow steps
time docker build -t myapp .
# Verbose curl for API calls in pipelines
curl -v -o /dev/null -w "\nHTTP Status: %{http_code}\nTime: %{time_total}s\n" \
https://api.example.com/health