The Bash Loop
Pre-Deploy Checklist
Hey engineer,
Today’s a polished, reusable pre-deploy checklist script you can run locally or in CI. It validates infra, secrets, artifacts and quick smoke tests before you press “deploy”.
It runs a set of fast, deterministic checks before a deploy (terraform plan sanity, k8s manifest validation, required env vars/secrets, Docker image existence, lint/tests, and a quick health endpoint check).
It’s modular, opinionated-but-flexible, uses safe env var handling (${VAR:?}), prints nice colored output, supports a dry-run/CI mode, and exits non-zero if mandatory checks fail.
predeploy-check.sh
#!/usr/bin/env bash
# predeploy-check.sh
# Run a set of lightweight pre-deploy checks.
#
# Usage:
# ./predeploy-check.sh [--continue-on-error] [--dry-run]
# Environment (examples):
# REQUIRED_ENVS=”AWS_REGION DB_URL”
# TERRAFORM_DIR=”./infra”
# K8S_MANIFEST_DIR=”./k8s”
# DOCKER_IMAGES=”myorg/web:latest,myorg/worker:sha-abc”
# HEALTHCHECK_URL=”https://staging.example.com/healthz”
# TEST_CMD=”pytest -q”
# LINT_CMD=”shellcheck -x ./scripts/*.sh”
#
set -euo pipefail
# ----- config (tweak as you like) -----
CONTINUE_ON_ERROR=0
DRY_RUN=0
REQUIRED_CMDS=(”curl” “jq”)
REQUIRED_ENVS=${REQUIRED_ENVS:-”“} # space-separated names, e.g. “GITLAB_TOKEN DB_URL”
TERRAFORM_DIR=${TERRAFORM_DIR:-”“} # optional: run terraform plan -detailed-exitcode
K8S_MANIFEST_DIR=${K8S_MANIFEST_DIR:-”“} # optional: run kubectl apply --dry-run=client
DOCKER_IMAGES=${DOCKER_IMAGES:-”“} # comma-separated images to check
HEALTHCHECK_URL=${HEALTHCHECK_URL:-”“} # optional: quick smoke endpoint
TEST_CMD=${TEST_CMD:-”“} # optional test command to run (unit/integration)
LINT_CMD=${LINT_CMD:-”“} # optional lint command
TIMEOUT_HTTP=10
TF_PLAN_TIMEOUT=120
# --------------------------------------
# ANSI colors
RED=$’\e[31m’; GREEN=$’\e[32m’; YELLOW=$’\e[33m’; BLUE=$’\e[34m’; RESET=$’\e[0m’
fail_count=0
warn_count=0
_ok() { printf “${GREEN}OK${RESET}”; echo; }
_warn() { printf “${YELLOW}WARN${RESET}”; echo; }
_err() { printf “${RED}FAIL${RESET}”; echo; }
usage() {
cat <<EOF
predeploy-check.sh -- lightweight pre-deploy validations
Options:
--continue-on-error keep running checks even if some fail (exit != 0 at end)
--dry-run print what would run but skip heavy ops (like terraform apply)
-h, --help show this help
EOF
exit 0
}
# parse args
while [[ $# -gt 0 ]]; do
case “$1” in
--continue-on-error) CONTINUE_ON_ERROR=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage ;;
*) echo “Unknown arg: $1”; usage ;;
esac
done
log() { printf “%s\n” “$*”; }
info() { printf “${BLUE}%s${RESET}\n” “$*”; }
ok() { printf “${GREEN}%s${RESET}\n” “$*”; }
err() { printf “${RED}%s${RESET}\n” “$*”; }
warning() { printf “${YELLOW}%s${RESET}\n” “$*”; }
record_fail() {
((fail_count++))
if [[ $CONTINUE_ON_ERROR -eq 0 ]]; then
err “Aborting due to failure.”
exit 1
fi
}
record_warn() { ((warn_count++)); }
# ------------------ checks ------------------
check_commands() {
info “Checking required commands: ${REQUIRED_CMDS[*]}”
for cmd in “${REQUIRED_CMDS[@]}”; do
if ! command -v “$cmd” >/dev/null 2>&1; then
err “Missing required command: $cmd”
record_fail
else
ok “command present: $cmd”
fi
done
}
check_envs() {
if [[ -z “${REQUIRED_ENVS// }” ]]; then
info “No required env vars configured (REQUIRED_ENVS empty)”
return
fi
info “Checking required environment variables...”
for var in $REQUIRED_ENVS; do
# Fail fast with clear message if missing or empty
if [[ -z “${!var:-}” ]]; then
err “Environment variable not set or empty: ${var}”
record_fail
else
ok “env: ${var} is set”
fi
done
}
check_terraform_plan() {
if [[ -z “${TERRAFORM_DIR}” ]]; then
info “Terraform check skipped (TERRAFORM_DIR not set)”
return
fi
if ! command -v terraform >/dev/null 2>&1; then
warning “terraform binary not found; skipping terraform plan check”
record_warn
return
fi
info “Running terraform init & plan in ${TERRAFORM_DIR} (timeout ${TF_PLAN_TIMEOUT}s)...”
if [[ $DRY_RUN -eq 1 ]]; then
warning “dry-run: skipping terraform plan”
return
fi
pushd “${TERRAFORM_DIR}” >/dev/null || { err “Cannot cd to ${TERRAFORM_DIR}”; record_fail; return; }
terraform init -input=false -no-color >/dev/null || { err “terraform init failed”; record_fail; popd >/dev/null; return; }
# -detailed-exitcode: 0=no changes, 2=changes, 1=error
if timeout “${TF_PLAN_TIMEOUT}” terraform plan -input=false -detailed-exitcode -no-color -out=tfplan >/dev/null 2>&1; then
ok “terraform plan: no changes”
else
rc=$?
if [[ $rc -eq 2 ]]; then
warning “terraform plan: changes detected (review required). See ${TERRAFORM_DIR}/tfplan”
record_warn
else
err “terraform plan failed (exit ${rc})”
record_fail
fi
fi
popd >/dev/null
}
check_k8s_manifests() {
if [[ -z “${K8S_MANIFEST_DIR}” ]]; then
info “K8s manifest check skipped (K8S_MANIFEST_DIR not set)”
return
fi
if ! command -v kubectl >/dev/null 2>&1; then
warning “kubectl not found; skipping k8s validation”
record_warn
return
fi
info “Validating Kubernetes manifests in ${K8S_MANIFEST_DIR} (client dry-run)...”
if [[ $DRY_RUN -eq 1 ]]; then
warning “dry-run: skipping kubectl apply --dry-run=client”
return
fi
failed=0
while IFS= read -r -d ‘’ file; do
if ! kubectl apply --dry-run=client -f “$file” >/dev/null 2>&1; then
err “k8s manifest validation failed: $file”
failed=1
else
ok “k8s manifest ok: $file”
fi
done < <(find “$K8S_MANIFEST_DIR” -type f -name ‘*.yaml’ -print0)
if [[ $failed -eq 1 ]]; then
record_fail
fi
}
check_docker_images() {
if [[ -z “${DOCKER_IMAGES//, }” ]]; then
info “Docker image check skipped (DOCKER_IMAGES empty)”
return
fi
info “Checking Docker images exist in registry...”
IFS=’,’ read -r -a images <<< “$DOCKER_IMAGES”
for img in “${images[@]}”; do
img_trimmed=$(echo “$img” | xargs)
# prefer skopeo if available (no daemon)
if command -v skopeo >/dev/null 2>&1; then
if skopeo inspect “docker://$img_trimmed” >/dev/null 2>&1; then
ok “image exists: $img_trimmed”
else
err “image not found via skopeo: $img_trimmed”
record_fail
fi
else
# fallback to docker pull (may require docker daemon)
if [[ $DRY_RUN -eq 1 ]]; then
warning “dry-run: skipping docker pull for $img_trimmed”
continue
fi
if docker pull “$img_trimmed” >/dev/null 2>&1; then
ok “docker pull succeeded: $img_trimmed”
else
err “docker pull failed: $img_trimmed”
record_fail
fi
fi
done
}
check_lint_and_tests() {
if [[ -n “${LINT_CMD}” ]]; then
info “Running lint: ${LINT_CMD}”
if [[ $DRY_RUN -eq 1 ]]; then
warning “dry-run: skipping lint command”
else
if bash -c “${LINT_CMD}”; then ok “lint passed”; else err “lint failed” && record_fail; fi
fi
else
info “Lint check skipped (LINT_CMD empty)”
fi
if [[ -n “${TEST_CMD}” ]]; then
info “Running tests: ${TEST_CMD}”
if [[ $DRY_RUN -eq 1 ]]; then
warning “dry-run: skipping tests”
else
if bash -c “${TEST_CMD}”; then ok “tests passed”; else err “tests failed” && record_fail; fi
fi
else
info “Test check skipped (TEST_CMD empty)”
fi
}
check_health() {
if [[ -z “${HEALTHCHECK_URL}” ]]; then
info “Health check skipped (HEALTHCHECK_URL empty)”
return
fi
info “Checking health endpoint: ${HEALTHCHECK_URL}”
if curl -fsS --max-time ${TIMEOUT_HTTP} “${HEALTHCHECK_URL}” >/dev/null 2>&1; then
ok “healthcheck ok: ${HEALTHCHECK_URL}”
else
err “healthcheck failed: ${HEALTHCHECK_URL}”
record_fail
fi
}
# ------------------ main ------------------
info “Pre-deploy checks starting...”
check_commands
check_envs
check_terraform_plan
check_k8s_manifests
check_docker_images
check_lint_and_tests
check_health
info “Summary: failures=${fail_count}, warnings=${warn_count}”
if [[ $fail_count -gt 0 ]]; then
err “Pre-deploy checks failed. Fix issues and re-run.”
exit 2
fi
if [[ $warn_count -gt 0 ]]; then
warning “Pre-deploy checks completed with warnings. Review them before deploying.”
else
ok “All pre-deploy checks passed. You’re good to deploy!”
fi
exit 0
Notes
Fail-fast vs continue: default behavior aborts on the first critical failure. Use
--continue-on-errorin exploratory runs to collect all failures at once.Dry-run is useful locally or in PRs where you don’t want to contact registries or run
terraform plan.Terraform uses
-detailed-exitcodeso CI can tell “no changes” vs “changes” vs “error”.Kubernetes validation uses
kubectl apply --dry-run=client(works for basic validation). For stronger validation, integratekubevalorconftest(rego).Docker image checks try
skopeofirst (no Docker daemon). If unavailable, falls back todocker pull.Environment checks use existence & non-empty checks. Prefer
${VAR:?}at the top of your CI job to fail earlier. This script reports missing envs clearly.Tests & linting are customizable, so wire your project’s commands via
TEST_CMDandLINT_CMD.Healthcheck is a last-minute smoke test against a staging endpoint (optional).
Example: Run locally
export REQUIRED_ENVS=”DB_URL GITLAB_TOKEN”
export TERRAFORM_DIR=”./infra”
export K8S_MANIFEST_DIR=”./k8s”
export DOCKER_IMAGES=”ghcr.io/myorg/web:staging,ghcr.io/myorg/worker:staging”
export HEALTHCHECK_URL=”https://staging.example.com/healthz”
export TEST_CMD=”pytest -q”
export LINT_CMD=”shellcheck -x ./scripts/*.sh”
# Quick check (dry-run)
./predeploy-check.sh --dry-run
# Full check (CI)
./predeploy-check.sh
Example GitHub Actions job (CI gate)
name: pre-deploy-check
on:
workflow_dispatch:
pull_request:
jobs:
predeploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup docker (if necessary)
uses: docker/setup-buildx-action@v2
- name: Install skopeo (optional)
run: sudo apt-get update && sudo apt-get install -y skopeo
- name: Run pre-deploy checks
env:
REQUIRED_ENVS: “DB_URL GITHUB_TOKEN”
TERRAFORM_DIR: infra
K8S_MANIFEST_DIR: k8s
DOCKER_IMAGES: “ghcr.io/myorg/web:${{ github.sha }},ghcr.io/myorg/worker:${{ github.sha }}”
HEALTHCHECK_URL: ${{ secrets.STAGING_HEALTHCHECK }}
TEST_CMD: “pytest -q”
LINT_CMD: “shellcheck -x ./scripts/*.sh”
run: |
chmod +x ./.github/scripts/predeploy-check.sh
./.github/scripts/predeploy-check.sh
Best practices & tips
Use
REQUIRED_ENVSin CI with${VAR:?}at the job level to fail early and give clear messages. Example in GitHub Actions:
env:
DB_URL: ${{ secrets.DB_URL }}
# cause job to fail early if unset:
run: |
: “${DB_URL:?DB_URL is required}”
Store sensitive vars in secrets manager (GitHub/GitLab secrets, Vault) and just reference them as envs in CI.
Keep checks fast. The goal is to catch obvious mistakes before deploy, not to run a full test suite (unless that’s your policy).
Centralize enhanced checks (security scans, long integration tests) in separate nightly or pre-prod pipelines.
TL;DR
Copy
predeploy-check.shinto your repo (e.g.,.github/scripts/) and wire it into your CI as a gating job.Configure environment variables to reflect your project (terraform dir, k8s manifests, images, tests).
Use
--dry-runlocally for quick validation; CI runs should be authoritative.Fail early on missing envs and broken plans and save yourself deploy-time pain.
Until next time,
Stay sharp.
For those that want speed, reliability and best practices without starting from scratch, I released CI/CD Pipeline Templates — a collection of fully functional pipelines for GitHub Actions, GitLab CI, Jenkins, and CircleCI.
Don’t waste valuable time writing Bash scripts from scratch.
The DevOps Automation Pack gives you 60+ plug-and-play production-ready Bash scripts for backups, monitoring, security, cleanup, and more.
Every script is cron-ready, supports dry-run mode, and works right out of the box.

