Shell ScriptingReal-World ProjectAdvancedDeploymentDevOpsMay 2026

Shell Scripting Real-World Projects: Automated Deployment Script

Build a production deployment script with pre-flight checks, git clone, build, database migration, atomic symlink switch, php-fpm and nginx reload, smoke testing, and one-command rollback.

A deployment script is the highest-stakes shell script you'll write. It touches every layer — git, build tools, databases, services, and web servers — in a specific order, with rollback at each step. This page builds a complete, production-quality deployment script from scratch.

BASH
#!/usr/bin/env bash
# deploy.sh — Zero-downtime web application deployment

set -euo pipefail

APP_NAME="myapp"
APP_DIR="/opt/${APP_NAME}"
RELEASES_DIR="${APP_DIR}/releases"
SHARED_DIR="${APP_DIR}/shared"
CURRENT="${APP_DIR}/current"
DEPLOY_USER="deploy"
REPO="git@github.com:myorg/myapp.git"
BRANCH="${1:-main}"
SLACK_URL="${SLACK_WEBHOOK_URL:-}"
KEEP_RELEASES=5

# ── Helpers ───────────────────────────────────────────────
log()   { echo "[$(date +%H:%M:%S)] $*"; }
step()  { echo; log "▶ $*"; }
slack() { [[ -n "${SLACK_URL}" ]] && curl -s -X POST "${SLACK_URL}" \
            -d "{\"text\":\"$*\"}" &>/dev/null; }
die()   { log "FATAL: $*"; slack "🔴 Deploy FAILED on $(hostname): $*"; exit 1; }

RELEASE_ID=$(date +%Y%m%d_%H%M%S)
RELEASE_DIR="${RELEASES_DIR}/${RELEASE_ID}"

log "=== Deploying ${APP_NAME}:${BRANCH} ==="
log "Release: ${RELEASE_ID}"
slack "🚀 Deploy started: ${APP_NAME}:${BRANCH} by ${USER} on $(hostname)"

# ── Pre-flight checks ─────────────────────────────────────
step "Pre-flight checks"
[[ "$(id -un)" == "${DEPLOY_USER}" ]] || die "Must run as ${DEPLOY_USER}"
command -v git &>/dev/null || die "git not found"
mysql --defaults-file=/etc/myapp/mysql.conf -e "SELECT 1" &>/dev/null || die "DB unreachable"
redis-cli PING &>/dev/null | grep -q PONG || die "Redis unreachable"
log "  Pre-flight: OK"

# ── Fetch code ────────────────────────────────────────────
step "Fetching code"
mkdir -p "${RELEASES_DIR}" "${SHARED_DIR}/logs" "${SHARED_DIR}/uploads"
git clone --depth 1 --branch "${BRANCH}" "${REPO}" "${RELEASE_DIR}"
GIT_SHA=$(git -C "${RELEASE_DIR}" rev-parse --short HEAD)
log "  SHA: ${GIT_SHA}"

# ── Link shared resources ─────────────────────────────────
step "Linking shared resources"
ln -sfn "${SHARED_DIR}/logs"    "${RELEASE_DIR}/storage/logs"
ln -sfn "${SHARED_DIR}/uploads" "${RELEASE_DIR}/storage/uploads"
ln -sfn "${SHARED_DIR}/.env"    "${RELEASE_DIR}/.env"

# ── Build ─────────────────────────────────────────────────
step "Building"
cd "${RELEASE_DIR}"
composer install --no-dev --no-interaction --quiet
npm ci --silent
npm run build --silent
log "  Build: OK"

# ── Run database migrations ───────────────────────────────
step "Running migrations"
DRY_RUN=false ./scripts/migrate.sh || die "Migration failed"
log "  Migrations: OK"

# ── Warm cache ────────────────────────────────────────────
step "Warming cache"
php artisan config:cache --no-interaction 2>/dev/null || true
php artisan route:cache  --no-interaction 2>/dev/null || true

# ── Atomic switch ─────────────────────────────────────────
step "Switching to new release"
PREVIOUS=$(readlink -f "${CURRENT}" 2>/dev/null || echo "")
ln -sfn "${RELEASE_DIR}" "${CURRENT}.new"
mv -T "${CURRENT}.new" "${CURRENT}"       # atomic rename
log "  Switch: OK → ${RELEASE_ID}"

# ── Reload services ───────────────────────────────────────
step "Reloading services"
sudo systemctl reload php8.3-fpm || die "php-fpm reload failed"
sudo nginx -t && sudo nginx -s reload || die "nginx reload failed"

# ── Smoke test ────────────────────────────────────────────
step "Smoke test"
sleep 2
HTTP_STATUS=$(curl -sL -o /dev/null -w "%{http_code}" "https://myapp.example.com/health")
[[ "${HTTP_STATUS}" == "200" ]] || die "Smoke test failed: HTTP ${HTTP_STATUS}"
log "  Smoke test: HTTP 200 OK"

# ── Clean up old releases ─────────────────────────────────
step "Pruning old releases"
ls -dt "${RELEASES_DIR}"/[0-9]* | tail -n "+$((KEEP_RELEASES+1))" | xargs rm -rf --
log "  Keeping last ${KEEP_RELEASES} releases"

log ""
log "=== Deploy complete: ${RELEASE_ID} (${GIT_SHA}) ==="
slack "✅ Deploy complete: ${APP_NAME}:${BRANCH} (${GIT_SHA}) — $(hostname)"
BASH
#!/usr/bin/env bash
# rollback.sh — Rollback to the previous release

set -euo pipefail

APP_DIR="/opt/myapp"
RELEASES_DIR="${APP_DIR}/releases"
CURRENT="${APP_DIR}/current"

CURRENT_RELEASE=$(basename "$(readlink -f "${CURRENT}")")
PREVIOUS_RELEASE=$(ls -dt "${RELEASES_DIR}"/[0-9]* | sed -n '2p' | xargs basename)

[[ -z "${PREVIOUS_RELEASE}" ]] && { echo "No previous release to roll back to"; exit 1; }

echo "Current:  ${CURRENT_RELEASE}"
echo "Rolling back to: ${PREVIOUS_RELEASE}"
read -rp "Proceed? [yes/no]: " CONFIRM
[[ "${CONFIRM}" == "yes" ]] || { echo "Aborted"; exit 0; }

ln -sfn "${RELEASES_DIR}/${PREVIOUS_RELEASE}" "${CURRENT}.rollback"
mv -T "${CURRENT}.rollback" "${CURRENT}"

sudo systemctl reload php8.3-fpm
sudo nginx -s reload

echo "Rolled back to ${PREVIOUS_RELEASE}"
deploy.sh output
vriddh@prod-01:~$./deploy.sh main
[14:22:01] === Deploying myapp:main ===
[14:22:01] Release: 20260501_142201
[14:22:01] ▶ Pre-flight checks
[14:22:02] Pre-flight: OK
[14:22:02] ▶ Fetching code
[14:22:08] SHA: a3f91bc
[14:22:08] ▶ Building
[14:22:31] Build: OK
[14:22:31] ▶ Running migrations
[14:22:32] Migrations: OK
[14:22:32] ▶ Switching to new release
[14:22:32] Switch: OK → 20260501_142201
[14:22:32] ▶ Smoke test
[14:22:34] Smoke test: HTTP 200 OK
[14:22:34] === Deploy complete: 20260501_142201 (a3f91bc) ===
✔ Deployment rules — Use atomic mv -T symlink.new symlink for zero-downtime switch. Run pre-flight database checks before touching any code. Always smoke-test after reload — catch HTTP 5xx before declaring success. Store releases in timestamped directories and symlink current. Keep the last N releases so rollback is one atomic symlink swap away. Send Slack notifications on both start and completion so the team always knows the deploy state.