A Bash deploy script isn’t just a sequence of commands; it’s an automated contract between your code and your infrastructure.

Let’s watch one in action. Imagine we’re deploying a simple web application.

#!/usr/bin/env bash

# --- Configuration ---
APP_NAME="my-web-app"
REMOTE_USER="deployer"
REMOTE_HOST="app.example.com"
REMOTE_DIR="/srv/www/${APP_NAME}"
GIT_BRANCH="main"
HEALTH_CHECK_URL="http://localhost:8080/health"
HEALTH_CHECK_TIMEOUT=30 # seconds
ROLLBACK_DIR="/srv/www/${APP_NAME}.rollback"

# --- Functions ---
log() {
  echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
}

run_remote() {
  ssh "${REMOTE_USER}@${REMOTE_HOST}" "$@"
}

# --- Deployment Steps ---
log "Starting deployment of ${APP_NAME}..."

# 1. Fetch latest code from Git
log "Fetching latest code from Git branch ${GIT_BRANCH}..."
run_remote "cd ${REMOTE_DIR} && git fetch origin && git reset --hard origin/${GIT_BRANCH}"
if [ $? -ne 0 ]; then
  log "ERROR: Failed to fetch or reset code. Aborting."
  exit 1
fi

# 2. Build the application (if necessary)
# This is a placeholder. Real build steps depend on your app (e.g., npm install, make, docker build)
log "Building application..."
run_remote "cd ${REMOTE_DIR} && npm install && npm run build"
if [ $? -ne 0 ]; then
  log "ERROR: Application build failed. Aborting."
  exit 1
fi

# 3. Create a backup of the current release
log "Creating backup of current release..."
run_remote "if [ -d ${REMOTE_DIR}/current ]; then cp -a ${REMOTE_DIR}/current ${ROLLBACK_DIR}/$(date '+%Y%m%d%H%M%S'); else mkdir -p ${ROLLBACK_DIR}; fi"
if [ $? -ne 0 ]; then
  log "WARNING: Failed to create backup. Continuing but proceeding with caution."
fi

# 4. Deploy the new release
log "Deploying new release..."
# This assumes you have a 'dist' or 'build' folder that contains the deployable artifacts
run_remote "rm -rf ${REMOTE_DIR}/current && mv ${REMOTE_DIR}/dist ${REMOTE_DIR}/current"
if [ $? -ne 0 ]; then
  log "ERROR: Failed to deploy new release. Aborting."
  # Attempt to restore from backup if it exists
  if run_remote "[ -d ${ROLLBACK_DIR} ] && ls -t ${ROLLBACK_DIR} | head -n 1"; then
    LATEST_BACKUP=$(run_remote "ls -t ${ROLLBACK_DIR} | head -n 1")
    log "Attempting to rollback to ${LATEST_BACKUP}..."
    run_remote "rm -rf ${REMOTE_DIR}/current && mv ${ROLLBACK_DIR}/${LATEST_BACKUP} ${REMOTE_DIR}/current"
    if [ $? -ne 0 ]; then
      log "ERROR: Rollback also failed. Manual intervention required."
    else
      log "Rollback successful."
    fi
  else
    log "No backup found to rollback to. Manual intervention required."
  fi
  exit 1
fi

# 5. Restart the application service
log "Restarting application service..."
run_remote "sudo systemctl restart ${APP_NAME}.service"
if [ $? -ne 0 ]; then
  log "ERROR: Failed to restart application service. Aborting."
  # Attempt to rollback
  if run_remote "[ -d ${ROLLBACK_DIR} ] && ls -t ${ROLLBACK_DIR} | head -n 1"; then
    LATEST_BACKUP=$(run_remote "ls -t ${ROLLBACK_DIR} | head -n 1")
    log "Attempting to rollback to ${LATEST_BACKUP}..."
    run_remote "rm -rf ${REMOTE_DIR}/current && mv ${ROLLBACK_DIR}/${LATEST_BACKUP} ${REMOTE_DIR}/current"
    if [ $? -ne 0 ]; then
      log "ERROR: Rollback also failed. Manual intervention required."
    else
      log "Rollback successful."
    fi
  else
    log "No backup found to rollback to. Manual intervention required."
  fi
  exit 1
fi

# 6. Health Check
log "Performing health check..."
HEALTH_STATUS=1
for i in {1..5}; do # Retry up to 5 times
  log "Health check attempt ${i}..."
  if run_remote "curl -s --fail --max-time ${HEALTH_CHECK_TIMEOUT} ${HEALTH_CHECK_URL}"; then
    HEALTH_STATUS=0
    log "Health check passed."
    break
  else
    log "Health check failed. Waiting 5 seconds before retrying..."
    sleep 5
  fi
done

if [ ${HEALTH_STATUS} -ne 0 ]; then
  log "ERROR: Health check failed after multiple attempts. Aborting."
  # Attempt to rollback
  if run_remote "[ -d ${ROLLBACK_DIR} ] && ls -t ${ROLLBACK_DIR} | head -n 1"; then
    LATEST_BACKUP=$(run_remote "ls -t ${ROLLBACK_DIR} | head -n 1")
    log "Attempting to rollback to ${LATEST_BACKUP}..."
    run_remote "rm -rf ${REMOTE_DIR}/current && mv ${ROLLBACK_DIR}/${LATEST_BACKUP} ${REMOTE_DIR}/current"
    if [ $? -ne 0 ]; then
      log "ERROR: Rollback also failed. Manual intervention required."
    else
      log "Rollback successful."
    fi
  else
    log "No backup found to rollback to. Manual intervention required."
  fi
  exit 1
fi

log "Deployment of ${APP_NAME} completed successfully!"
exit 0

This script embodies the "release directory" pattern. Instead of overwriting files in place, each deployment creates a new, distinct directory (e.g., /srv/www/my-web-app/releases/20230101120000). A symbolic link, current, always points to the active release. This makes rollbacks instantaneous: just point current to the previous release directory.

The core problem this solves is managing state and ensuring atomicity during updates. If any step fails after the old release is removed, we need a way to revert. The backup and rollback logic here is a simplified version of that. The run_remote function is key, abstracting the SSH calls. The log function ensures we have a timestamped audit trail. The health check is the final gatekeeper, verifying that the new release is actually functional before declaring victory.

The most surprising thing about creating robust deploy scripts is how much they resemble a financial transaction ledger. Each deployment is an atomic "commit," and the ability to roll back is like having an undo button that doesn’t corrupt data. You’re not just copying files; you’re managing versions and ensuring transactional integrity across your environment. The symbolic link current acts as the ledger’s "pointer" to the latest valid entry.

When you get comfortable with this, the next logical step is integrating this script into a CI/CD pipeline, turning your manual deployment into an automated, trigger-based process.

Want structured learning?

Take the full Bash course →