A deployment script removes the manual steps from releasing software. Instead of following a checklist of commands that can be run in the wrong order, forgotten, or mistyped, a well-written Bash script automates the entire process: pulling the latest code, installing dependencies, running database migrations, clearing caches, and restarting services. The script becomes the single source of truth for how to get code from a repository into a running application.

This guide covers the key components of a production-ready deployment script, the common failure points to avoid, and how to structure scripts so that they fail safely and provide useful output when something goes wrong. Whether you are deploying PHP applications, static sites, or custom software, the principles remain the same: automation, reliability, and the ability to roll back when things go sideways.

What a Deployment Script Should Do

A deployment script handles the full sequence of steps required to take new code and make it live. The exact steps depend on your application and infrastructure, but the typical sequence looks like this:

  • Connect to the server: Either through direct SSH or a tool like Ansible that runs without interactive login.
  • Pull the latest code: Clone a repository or pull from a version control system like Git.
  • Install or update dependencies: Composer for PHP projects, npm for Node applications, pip for Python, or similar package managers.
  • Run database migrations or schema updates: Apply any pending changes to the database structure or seed data.
  • Clear application caches: Remove stale cached files so the application loads fresh code.
  • Restart application services: Reload PHP-FPM, restart queue workers, or restart the application server.
  • Verify the deployment: Check that the application responds correctly and the deployment completed without errors.

Each step should be a discrete function in the script so that failures can be caught at the step level. When something breaks, the script should exit with a meaningful error rather than continuing with a partially completed deployment that leaves the system in an inconsistent state.

Foundational Bash Scripting Concepts

Before building a deployment script, you need solid Bash scripting fundamentals. Variables, conditionals, functions, and error handling form the backbone of any reliable automation. If you are new to Bash scripting or want to refresh the basics, a guide on Bash scripting basics for automation covers the essential concepts you will use in almost every deployment script you write.

Key concepts to master include variable declaration and expansion, how to check exit codes after running commands, using conditional statements to branch based on command results, and writing reusable functions that encapsulate discrete tasks. These skills directly translate into writing deployment scripts that are maintainable, testable, and easier to debug when something goes wrong.

Safe SSH and Remote Execution

The most basic approach to deployment is running commands on a remote server via SSH. This works well for simple applications, but the security and reliability of your SSH setup matters significantly.

Use SSH key-based authentication with a dedicated deploy key that has only the access required for deployment. This key should not have full sudo access. Store the private key securely on the machine that runs the deployment script and never commit it to version control. If the private key lives on a CI/CD runner, use the runner's secret storage mechanism to inject it at runtime.

# Deploy using SSH with key authentication
ssh -i /path/to/deploy_key -o StrictHostKeyChecking=no [email protected] \
 'bash -s' << 'ENDOFSCRIPT'
cd /var/www/yourapp
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan cache:clear
systemctl reload php-fpm
ENDOFSCRIPT

StrictHostKeyChecking=no prevents the script from hanging when connecting to a new server for the first time. However, in production environments with persistent servers, it is worth adding the host key to known_hosts beforehand to prevent potential man-in-the-middle attacks.

Our SSH hardening guide covers how to configure SSH access securely, including key-based authentication, fail2ban configuration to prevent brute force attacks, and limiting which commands the deploy key can run.

Structuring Scripts with Functions and Exit Codes

A deployment script that is one long sequence of commands is difficult to debug and maintain. Break the script into discrete functions, each handling one step of the deployment. Each function should check the exit code of the commands it runs and return an appropriate exit code to the calling code.

#!/bin/bash
set -euo pipefail

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}

error_exit() {
    log "ERROR: $1" >&2
    exit 1
}

pull_code() {
    log "Pulling latest code from repository"
    cd /var/www/yourapp || error_exit "Cannot change to application directory"
    git pull origin main || error_exit "Git pull failed"
}

install_dependencies() {
    log "Installing dependencies"
    composer install --no-dev --optimize-autoloader \
        || error_exit "Composer install failed"
}

run_migrations() {
    log "Running database migrations"
    php artisan migrate --force || error_exit "Migration failed"
}

deploy() {
    pull_code
    install_dependencies
    run_migrations
    log "Deployment completed successfully"
}

deploy

The set -euo pipefail line at the top of the script is one of the most important lines you will write. It tells Bash to exit immediately if any command fails (-e), to treat unset variables as errors (-u), and to return the exit code of the last command in a pipeline that failed rather than the exit code of the last command in the pipeline (-pipefail).

Rollback: The Most Important Feature

A deployment script that cannot roll back is incomplete. Every deployment should create a rollback point before making changes. Without a reliable rollback procedure, a failed deployment means manual intervention under pressure, which is when mistakes happen.

Common rollback strategies include a symlink swap pointing from the current release directory to the previous one, a database backup taken before migrations run, and a tagged release directory structure that preserves previous versions. The approach you choose depends on your application architecture, but the principle is the same: capture enough state before the deployment so you can restore it quickly if needed.

The rollback procedure should be a separate script or a flag in the same script. Running ./deploy.sh --rollback should take the system back to the previous known-good state in seconds, not minutes of manual intervention. Test your rollback procedure before you need it. A rollback that has never been tested is not reliable.

# Keep releases in timestamped directories
CURRENT=/var/www/yourapp/current
RELEASES_DIR=/var/www/yourapp/releases
NEW_RELEASE=$RELEASES_DIR/$(date +%Y%m%d_%H%M%S)

# Before deploying, backup current
cp -r /var/www/yourapp/current /tmp/backup-$(date +%Y%m%d)

# After successful deploy, switch symlink
ln -sfn $NEW_RELEASE $CURRENT

# Rollback procedure
rollback() {
    log "Rolling back to previous release"
    if [ -d /tmp/backup-* ]; then
        LAST_BACKUP=$(ls -td /tmp/backup-* | head -1)
        cp -r $LAST_BACKUP/* /var/www/yourapp/current/
        log "Rollback completed"
    else
        error_exit "No backup found for rollback"
    fi
}

Notifications and Logging

Every deployment should generate a log that is stored and searchable. The log should include the exact commands run, their output, the user who triggered the deployment, the timestamp, and whether the deployment succeeded or failed. This log is invaluable for debugging problems that surface days after a deployment.

Send notifications on deployment completion or failure. A Slack webhook, an email to a monitoring address, or a simple entry in a monitoring system ensures that the team knows when something was deployed and whether it succeeded. Automated notifications without manual checking are the foundation of a reliable deployment process.

Our GitOps and GitHub Actions deployment guide covers how to integrate deployment scripting into a CI/CD pipeline so that deployments are automatically triggered by code changes rather than requiring manual script execution.

Idempotency: Scripts That Can Be Run Multiple Times Safely

A good deployment script is idempotent: running it twice in a row should not cause problems. If the script creates a directory that already exists, it should not fail. If it runs a migration that has already been applied, it should skip gracefully. Idempotency means that a failed deployment can be retried without causing additional problems.

Check for prerequisites before starting. If Composer is not installed, fail early with a clear message rather than running partial steps. If the database is not reachable, fail early. The script should fail fast and loudly rather than limp along in a partially deployed state.

# Check prerequisites
check_php() {
    if ! command -v php &> /dev/null; then
        error_exit "PHP is not installed"
    fi
}

check_composer() {
    if ! command -v composer &> /dev/null; then
        error_exit "Composer is not installed"
    fi
}

# Only create directory if it does not exist
ensure_directory() {
    if [ ! -d "$1" ]; then
        mkdir -p "$1"
        log "Created directory: $1"
    fi
}

Environment-Specific Configuration

Use environment variables or a configuration file for values that differ between environments. Database credentials, API keys, and service URLs vary between staging and production, and the deployment script should handle these differences cleanly.

The script should accept an environment argument and load the appropriate configuration. Running ./deploy.sh staging or ./deploy.sh production should load different settings while keeping the same deployment logic. Never hardcode credentials or paths that differ between environments directly in the script.

#!/bin/bash
ENVIRONMENT=${1:-production}
CONFIG_FILE="config.$ENVIRONMENT.sh"

if [ ! -f "$CONFIG_FILE" ]; then
    error_exit "Configuration file $CONFIG_FILE not found"
fi

source "$CONFIG_FILE"

The configuration file for each environment should define variables like database connection strings, service URLs, and feature flags. Keep these files outside the application repository if they contain sensitive values, or encrypt them at rest.

Credential Management in Deployment Scripts

Never hard-code database passwords, API keys, or SSH credentials in deployment scripts. Use environment variables or a secrets manager to inject credentials at deployment time. This keeps sensitive data out of version control and makes credential rotation easier.

# Load .env file if it exists
if [ -f .env ]; then
    set -a
    source .env
    set +a
fi

# Use the variables in commands
mysqldump -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" > backup.sql

For production deployments, use a secrets service like AWS Secrets Manager, HashiCorp Vault, or your hosting provider's secret storage. Retrieve secrets at deployment time rather than storing them on the server filesystem. Rotate credentials regularly and ensure the deployment script fails fast if any required secret is missing.

Zero-Downtime Deployment Strategy

For web applications with persistent processes like PHP-FPM or Node, zero-downtime deployments require careful handling of the symlink swap. Keep each deployment in a timestamped directory, then atomically update the symlink in a single operation.

RELEASE=$(date +%Y%m%d%H%M%S)
TARGET="/var/www/myapp/releases/$RELEASE"

# Clone or copy application to new release directory
git clone "$REPO_URL" "$TARGET"

# Run composer install and migrations
composer install --working-dir="$TARGET" --no-dev --optimize-autoloader
php "$TARGET/artisan" migrate --force

# Atomically swap the symlink
ln -sfn "$TARGET" /var/www/myapp/current

# Reload the application server
systemctl reload php-fpm

The ln -sfn operation is atomic at the filesystem level. The old code continues running until the last in-flight request finishes, and new requests immediately route to the new code. There is no downtime window, though you may want a brief health check delay before declaring the deployment complete.

Testing Deployment Scripts Before Running on Production

Never run a deployment script directly on production without testing it in a staging environment first. The staging environment should mirror production configuration as closely as possible, including the same operating system version, firewall rules, database version, and runtime version. Differences between staging and production are where subtle bugs hide.

Add a confirmation prompt in the deployment script that requires explicit user input before proceeding on production hosts. Check that the deploy user has exactly the permissions required for the job. Use set -e and set -u at the top of every script. This prevents silent failures where a command in the middle of the script fails but the script continues and corrupts the deployment state.

confirm_production() {
    if [ "$ENVIRONMENT" = "production" ]; then
        echo "You are about to deploy to PRODUCTION."
        read -p "Type 'yes' to confirm: " confirmation
        if [ "$confirmation" != "yes" ]; then
            error_exit "Deployment cancelled"
        fi
    fi
}

Database Migration Strategy During Deployment

Database migrations are the most dangerous part of a deployment because they are difficult to roll back cleanly. The golden rule is to always run database migrations before swapping the application symlink, and to ensure migrations are backwards-compatible.

Backwards-compatible means the new code must work with the old database schema. If you need to add a column, add it as nullable first. If you need a new table, create it before deploying the new application version. This way, if a rollback is needed, the old application code can still operate against the current database structure.

Never drop columns or tables as part of a migration that coincides with a deployment. Do it as a separate step after the new application version is confirmed running. If there is a problem with the new application version, a rollback also restores the database to a compatible state.

  • Always backup before migrations: Take a database snapshot before running any schema changes.
  • Run migrations before symlink swap: Apply schema changes while the old application still serves requests.
  • Use backwards-compatible migrations: Add nullable columns, new tables, and new indexes that the old code can ignore.
  • Drop cleanup for a later release: Remove deprecated columns and tables only after the new application version has been stable for a full deployment cycle.

CI/CD Integration for PHP Projects

For PHP applications specifically, integrating deployment scripts into a CI/CD pipeline automates the entire workflow from code commit to live server. GitHub Actions can run your test suite, build assets, and trigger the deployment script in sequence.

A pipeline for a PHP project typically runs composer install, runs the test suite, builds frontend assets if needed, then calls the deployment script. The CI/CD guide for PHP projects with GitHub Actions covers the full setup including environment configuration, secret management, and deployment triggers.

Monitoring After Deployment

A deployment is not complete when the script finishes. Monitor the application for a period after deployment to catch any issues that did not surface during testing. Check error rates, response times, and application logs for any increase in exceptions or warnings.

Set up automated health checks that run after the deployment script completes. A simple curl request to the application endpoint, checking for a 200 response code, confirms the application is serving requests. More comprehensive checks can query specific endpoints or verify database connectivity.

health_check() {
    local url="${APP_URL}/health"
    local max_attempts=5
    local attempt=1

    while [ $attempt -le $max_attempts ]; do
        if curl -sf "$url" > /dev/null; then
            log "Health check passed"
            return 0
        fi
        log "Health check attempt $attempt/$max_attempts failed"
        sleep 5
        attempt=$((attempt + 1))
    done

    error_exit "Health check failed after $max_attempts attempts"
}