A deployment script built on rsync and Bash provides a fast, reliable, zero-downtime deployment workflow for WordPress on a VPS without requiring CI/CD infrastructure — it pushes only changed files, preserves file permissions, excludes development files and secrets, and runs WP-CLI cache-flush commands after transfer. The core command is rsync -az --delete --checksum --exclude-from=.rsyncignore src/ user@host:/var/www/site/: -a preserves permissions, timestamps, symlinks, and ownership; --delete removes files on the remote that no longer exist locally; --checksum compares file checksums instead of timestamps for accuracy; and --exclude-from reads exclusion patterns from a file. The .rsyncignore file must exclude wp-config.php (server-specific credentials), wp-content/uploads/ (user-uploaded files that differ between environments), .git/, node_modules/, *.log, and any local development configuration files. An atomic deployment pattern uses a staging directory on the server and an rsync --link-dest option that hardlinks unchanged files from the previous release — the new release directory is ready before it goes live, and a single ln -sfn symlink swap makes it the active webroot with no downtime window. SSH key authentication is required for automated scripts — password-based SSH authentication blocks non-interactive execution. The deployment script runs wp cache flush, wp rewrite flush, and optionally wp plugin update --all via ssh user@host "wp --path=/var/www/site cache flush" after rsync completes. A --dry-run flag passed to rsync previews all changes without applying them, which is useful for verifying what will be transferred before the actual deployment. The Git hooks post shows how to trigger this deployment script automatically from a post-receive hook on the remote Git repository.
Problem: Deploying WordPress theme and plugin changes to a VPS requires manually uploading files via FTP or SCP, clearing caches by hand, and hoping no files were missed — a process that takes 10–20 minutes per deployment and introduces human error.
Solution: Write a Bash deployment script that uses rsync to transfer only changed files while excluding secrets and uploads, runs WP-CLI post-deploy commands over SSH, and optionally implements an atomic symlink swap for zero-downtime deployments.
#!/usr/bin/env bash
# deploy.sh — deploy WordPress to VPS with rsync
set -euo pipefail
REMOTE_USER="deploy"
REMOTE_HOST="example.com"
REMOTE_PATH="/var/www/mysite"
LOCAL_PATH="./"
WP_CLI="wp --path=$REMOTE_PATH"
# Parse optional --dry-run flag
DRY_RUN=""
for arg in "$@"; do [[ "$arg" == "--dry-run" ]] && DRY_RUN="--dry-run"; done
[[ -n "$DRY_RUN" ]] && echo "DRY RUN — no changes will be made"
echo "Syncing files to $REMOTE_HOST:$REMOTE_PATH ..."
rsync -az --checksum --delete $DRY_RUN \
--exclude-from=".rsyncignore" \
--info=progress2 \
"$LOCAL_PATH" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/"
[[ -n "$DRY_RUN" ]] && { echo "Dry run complete."; exit 0; }
echo "Running post-deploy commands..."
ssh "$REMOTE_USER@$REMOTE_HOST" bash -s << EOF
set -e
$WP_CLI cache flush
$WP_CLI rewrite flush
$WP_CLI transient delete --all
echo "Post-deploy done."
EOF
echo "Deployment complete."
# .rsyncignore — files and directories to exclude from deployment
# WordPress environment-specific files
wp-config.php
.env
.env.*
# User-uploaded content (managed separately on the server)
wp-content/uploads/
wp-content/cache/
wp-content/upgrade/
# Development and build artifacts
.git/
.gitignore
node_modules/
vendor/
*.log
*.sql
*.zip
# Local dev tools
.DS_Store
deploy.sh
.rsyncignore
phpunit.xml
tests/
NOTE: Set up SSH key authentication before using this script — generate a key pair with ssh-keygen -t ed25519, copy the public key to the server with ssh-copy-id deploy@example.com, and restrict the deploy user’s shell to /bin/bash with no sudo access except for specific WP-CLI and service-reload commands listed in /etc/sudoers.d/deploy. Never store deployment credentials in the script itself.