Before commercial CI platforms dominated, teams ran their own build-and-deploy pipelines in bash. Understanding how to build one from scratch makes you a better engineer — you know what your CI platform is actually doing, and you can reproduce it locally or replace it entirely when needed.
1Complete CI/CD pipeline in bash
BASH
#!/usr/bin/env bash
# pipeline.sh — Complete CI/CD pipeline: test → build → deploy
set -euo pipefail
PIPELINE_ID="$(date +%Y%m%d%H%M%S)-${RANDOM}"
REPO_DIR="${1:-$(pwd)}"
BRANCH="${GIT_BRANCH:-$(git -C "${REPO_DIR}" rev-parse --abbrev-ref HEAD)}"
SHA="${GIT_SHA:-$(git -C "${REPO_DIR}" rev-parse --short HEAD)}"
SLACK_URL="${SLACK_WEBHOOK_URL:-}"
BUILD_LOG="/var/log/pipeline/${PIPELINE_ID}.log"
STAGES_PASSED=0
STAGES_TOTAL=0
mkdir -p "$(dirname "${BUILD_LOG}")"
# ── Stage runner ──────────────────────────────────────────
run_stage() {
local name="$1"; shift
(( STAGES_TOTAL++ ))
local start; start=$(date +%s)
echo ""
echo "┌─ Stage: ${name} ────────────────────────────────────"
if "$@" 2>&1 | tee -a "${BUILD_LOG}"; then
local elapsed=$(( $(date +%s) - start ))
echo "└─ ✔ ${name} [${elapsed}s]"
(( STAGES_PASSED++ ))
return 0
else
local elapsed=$(( $(date +%s) - start ))
echo "└─ ✘ ${name} FAILED [${elapsed}s]"
[[ -n "${SLACK_URL}" ]] && curl -s -X POST "${SLACK_URL}" \
-d "{\"text\":\"🔴 Pipeline FAILED at stage: ${name} | ${BRANCH}@${SHA}\"}" &>/dev/null
return 1
fi
}
echo "Pipeline: ${PIPELINE_ID}"
echo "Branch: ${BRANCH}@${SHA}"
echo "Log: ${BUILD_LOG}"
[[ -n "${SLACK_URL}" ]] && curl -s -X POST "${SLACK_URL}" \
-d "{\"text\":\"▶ Pipeline started: ${BRANCH}@${SHA}\"}" &>/dev/null
cd "${REPO_DIR}"
# ── Stage 1: Lint ─────────────────────────────────────────
run_stage "Lint" bash -c '
shellcheck bin/*.sh scripts/*.sh 2>/dev/null || true
php -l src/**/*.php 2>&1 | grep -v "No syntax errors" || true
echo " Lint: OK"
'
# ── Stage 2: Unit tests ───────────────────────────────────
run_stage "Unit Tests" bash -c '
export APP_ENV=testing
./vendor/bin/phpunit --no-coverage --testdox tests/Unit/
'
# ── Stage 3: Integration tests ────────────────────────────
run_stage "Integration Tests" bash -c '
export APP_ENV=testing
./vendor/bin/phpunit --no-coverage tests/Integration/
'
# ── Stage 4: Build assets ─────────────────────────────────
run_stage "Build Assets" bash -c '
npm ci --silent
npm run build
echo " Build size: $(du -sh public/build | cut -f1)"
'
# ── Stage 5: Security scan ────────────────────────────────
run_stage "Security Scan" bash -c '
composer audit --no-interaction 2>&1 | grep -v "^No security"
npm audit --audit-level=high 2>&1 | tail -5
echo " Security scan: OK"
'
# ── Stage 6: Deploy (main branch only) ────────────────────
if [[ "${BRANCH}" == "main" ]]; then
run_stage "Deploy" bash -c '
./scripts/deploy.sh main 2>&1
'
else
echo ""
echo " Skipping deploy: branch=${BRANCH} (only deploy on main)"
fi
# ── Summary ───────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════"
echo "Pipeline: ${PIPELINE_ID}"
echo "Result: ${STAGES_PASSED}/${STAGES_TOTAL} stages passed"
echo "Log: ${BUILD_LOG}"
[[ -n "${SLACK_URL}" ]] && curl -s -X POST "${SLACK_URL}" \
-d "{\"text\":\"$(( STAGES_PASSED == STAGES_TOTAL )) && echo ✅ || echo ⚠ Pipeline complete: ${STAGES_PASSED}/${STAGES_TOTAL} | ${BRANCH}@${SHA}\"}" &>/dev/null
(( STAGES_PASSED == STAGES_TOTAL ))
2Webhook trigger server
BASH
#!/usr/bin/env bash
# webhook_server.sh — Listen for GitHub webhooks and trigger pipeline
PORT="${WEBHOOK_PORT:-9000}"
SECRET="${WEBHOOK_SECRET:?WEBHOOK_SECRET required}"
while true; do
# Use netcat to receive one HTTP request
REQUEST=$(nc -l -p "${PORT}" -q 1 2>/dev/null)
# Extract signature and body
SIGNATURE=$(echo "${REQUEST}" | grep -i "X-Hub-Signature-256:" | \
sed 's/.*: //' | tr -d '\r')
BODY=$(echo "${REQUEST}" | awk '/^\r?$/{found=1; next} found{print}')
# Verify HMAC signature
EXPECTED="sha256=$(echo -n "${BODY}" | \
openssl dgst -sha256 -hmac "${SECRET}" | awk '{print $2}')"
if [[ "${SIGNATURE}" == "${EXPECTED}" ]]; then
BRANCH=$(echo "${BODY}" | python3 -c \
"import json,sys; d=json.load(sys.stdin); print(d.get('ref','').replace('refs/heads/',''))")
echo "Webhook: branch=${BRANCH}"
[[ "${BRANCH}" == "main" ]] && \
nohup /opt/scripts/pipeline.sh /opt/myapp &>/dev/null &
else
echo "Invalid webhook signature — ignoring"
fi
done
✔ CI/CD pipeline rules — Structure as discrete stages with clear pass/fail. Fail fast — stop the pipeline at the first failure rather than running all stages. Always post Slack notifications at start and end so the team sees progress. Run unit tests before integration tests — they're faster and catch most bugs first. Only deploy from the
main branch to prevent accidental production deploys from feature branches. Store build logs to a persistent location for debugging.