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"
}