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.
1Complete zero-downtime deployment script
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)"
2Rollback script
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}"
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.