LinuxBash ScriptingDevOps

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.

TT
Daniel Brooks
5 min read
Bash in CI/CD Pipelines: Writing Reliable Pipeline Scripts

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:

CharacteristicDeveloper MachineCI Runner
ShellInteractive, sourced .bashrcNon-interactive, minimal env
Working directoryArbitraryRepository root (usually)
CredentialsSSH agent, keychainInjected secrets / env vars
Installed toolsWhatever the dev hasOnly what the image provides
UserNamed user with home dirOften root or runner
Exit on errorNot setShould 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

bash
#!/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

yaml
# .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:

bash
# 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 practice

GitLab CI Integration

yaml
# .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:
    - main

Handling Secrets Safely in Scripts

Never hardcode secrets in scripts. The correct patterns:

bash
# 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 repo

Masking Secrets in Script Output

bash
# 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:

bash
#!/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

bash
#!/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

bash
# 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