What you will learn
Drive the same deploy script against dev, staging, and production by reading each environment's settings from a config file, instead of maintaining three near-duplicate scripts that drift apart.
Before you begin
Keep secrets (API tokens, passwords) out of the config file itself; reference an external secret store or environment variable name instead. This example uses a simple key=value file per environment.
1. Define one config file per environment
# config/production.env
APP_HOST="prod01.internal"
APP_USER="deploy"
RELEASE_DIR="/opt/myapp/releases"
HEALTH_URL="https://myapp.internal/healthz"
REQUIRE_APPROVAL="true"
2. Load it defensively — never source untrusted input blindly
#!/usr/bin/env bash
set -Eeuo pipefail
ENV_NAME="${1:?usage: deploy.sh <environment>}"
CONFIG_FILE="config/${ENV_NAME}.env"
[[ -f "$CONFIG_FILE" ]] || { echo "no config for '${ENV_NAME}'" >&2; exit 1; }
# Allow lines matching KEY=VALUE only — reject anything else before sourcing.
if grep -qvE '^\s*(#.*)?$|^[A-Za-z_][A-Za-z0-9_]*=.*$' "$CONFIG_FILE"; then
echo "refusing to load ${CONFIG_FILE}: unexpected content" >&2
exit 1
fi
# shellcheck source=/dev/null
source "$CONFIG_FILE"
The grep -qv guard rejects a config file containing anything beyond simple assignments — command substitution, function calls — before it's ever sourced, closing the classic "config file as arbitrary code execution" hole.
3. Gate risky environments behind an explicit confirmation
if [[ "${REQUIRE_APPROVAL:-false}" == "true" ]]; then
read -r -p "Deploy to ${ENV_NAME} (${APP_HOST})? Type the environment name to confirm: " confirm
[[ "$confirm" == "$ENV_NAME" ]] || { echo "confirmation mismatch, aborting" >&2; exit 1; }
fi
echo "deploying to ${ENV_NAME} (${APP_HOST}) as ${APP_USER}"
ssh "${APP_USER}@${APP_HOST}" "mkdir -p ${RELEASE_DIR}/$(date -u +%Y%m%d%H%M%S)"
# ... rsync release, run migrations, restart service ...
if retry_curl="$(curl -fsS --max-time 5 "$HEALTH_URL")"; then
echo "post-deploy health check ok: ${retry_curl}"
else
echo "post-deploy health check FAILED" >&2
exit 1
fi
Requiring the operator to type the environment's exact name (not just "y") is a deliberate friction point for production — it stops a fat-fingered deploy.sh production typed while meaning staging.
Example output
Deploy to production (prod01.internal)? Type the environment name to confirm: production
deploying to production (prod01.internal) as deploy
post-deploy health check ok: {"status":"ok"}
Verify the result
Run against a throwaway dev config first end-to-end. Confirm the config-content guard actually rejects a file containing $(rm -rf /) before trusting it with production credentials.
Troubleshooting
If source fails with "command not found," the guard's regex is too strict for a legitimate value containing = inside quotes — extend the allowed pattern deliberately rather than removing the guard.
Next steps
Route deploy failures through webhook-alert-dispatcher, and consider staged-pipeline-runner once the deploy grows beyond a single linear script.