WordPress Deployment with WP-CLI + Git Hooks
In 2026, WordPress deployment has been completely streamlined by WP-CLI + Git Hooks. No Jenkins, no GitHub Actions — just an SSH-accessible server. I spent one afternoon automating a workflow that used to take three years of manual deployments. This article documents my verified pipeline: Git repo initialization, server-side auto-pull, database sync across three environments, wp-config security separation, and rollback procedures.
Prerequisites
- **Local machine**: macOS or Linux with WP-CLI installed (`wp --info` to verify)
- **Remote server**: Ubuntu 22.04+, SSH key auth, WP-CLI installed
- **Code hosting**: GitHub / GitLab (examples use GitHub)
- **Database**: MySQL 8.0+ or MariaDB 10.6+, operated via WP-CLI
> **Note**: WordPress 6.7+ supports WP_CLI_REMOTE_CMD for remote execution, reducing SSH round-trips during deployment.
Local Project Setup
# Initialize Git in your project root
git init
# Create .gitignore (critical: exclude wp-config.php and uploads)
cat > .gitignore << 'EOF'
wp-config.php
wp-content/uploads/
wp-content/cache/
*.log
EOF
# Add production/staging ignores
echo "wp-config-prod.php" >> .gitignore
echo "wp-content/uploads/" >> .gitignore
git add .
git commit -m "chore: initial WordPress project structure"
Server-Side Auto-Pull: post-receive
On your **production server**, create hooks/post-receive inside a bare Git repo (/var/repo/your-site.git):
#!/bin/bash
# post-receive: auto-pull + cache flush + OPcache reload
TARGET="/var/www/your-site"
GIT_DIR="/var/repo/your-site.git"
while read oldrev newrev branch; do
if [ "$branch" = "refs/heads/main" ]; then
echo "🔄 Detected main branch push, deploying..."
# 1. Checkout latest code into target directory
GIT_WORK_TREE="$TARGET" git checkout -f main
# 2. wp-config.php stays outside Git (managed by system)
# 3. Flush WordPress object cache
wp cache flush --path="$TARGET"
# 4. Reload OPcache if using PHP-FPM
# systemctl reload php8.2-fpm # enable as needed
# 5. Verify WordPress autoload options count
wp shell --info --path="$TARGET" 2>/dev/null || true
echo "✅ Deploy complete: $(date '+%Y-%m-%d %H:%M:%S')"
fi
done
chmod +x hooks/post-receive
**Result**: Every git push triggers automatic code update and cache flush on the server. No manual SSH required.
Pre-Push Validation: pre-push
Create .git/hooks/pre-push locally (no extension):
#!/bin/bash
# pre-push: validate before pushing to protected branches
BRANCH=$(git symbolic-ref --short HEAD)
PROTECTED_BRANCHES="main production"
if echo "$PROTECTED_BRANCHES" | grep -q "$BRANCH"; then
echo "⚠️ Pushing to $BRANCH — branch protection active"
# Optional: run PHPStan / WordPress-Coding-Standards
# if ! composer phpcs; then
# echo "❌ Standards check failed, aborting push"
# exit 1
# fi
fi
echo "✅ pre-push check passed"
exit 0
chmod +x .git/hooks/pre-push
Database Sync: Three Scenario Commands
Scenario A: Pull Staging to Local
The most common use case: reproducing a staging environment bug locally for debugging.
# 1. Export from staging server (via SSH)
ssh user@staging-server "
wp db export - | gzip > /tmp/staging-db-$(date +%Y%m%d).sql.gz
"
# 2. Download locally
scp user@staging-server:/tmp/staging-db-$(date +%Y%m%d).sql.gz /tmp/
# 3. Import locally (backup current DB first)
wp db export /tmp/local-backup-$(date +%Y%m%d).sql
wp db drop --yes
wp db create
gunzip < /tmp/staging-db-$(date +%Y%m%d).sql.gz | wp db import -
# 4. Replace URLs (staging → local)
wp search-replace 'https://staging.example.com' 'http://localhost:8080' --export=/tmp/replace.sql
wp db query < /tmp/replace.sql
# 5. Clear caches
wp cache flush
wp rewrite flush
> **Note**: wp search-replace handles serialized data correctly in WordPress 5.1+. Always run wp core update-db after import to ensure DB version consistency.
Scenario B: Production → Staging (Scrubbed Data)
Testing with real production traffic in staging, with user data sanitized:
# 1. Export from production (excluding user-sensitive tables)
ssh user@prod-server "
wp db export - --exclude_tables=wp_users,wp_usermeta --add-drop-table | gzip
" > /tmp/prod-scrubbed.sql.gz
# 2. Import to staging
gunzip < /tmp/prod-scrubbed.sql.gz | wp db import -
wp search-replace 'https://your-site.com' 'https://staging.your-site.com' --precise
# 3. Create test account (never use real user data)
wp user create testuser test@example.com --role=administrator --user_pass=TestPass123
Scenario C: Staging → Production (Zero-Downtime Deploy)
The release workflow: enable maintenance mode first, then migrate the database:
# 1. Export staging DB
wp db export /tmp/staging-final.sql --add-drop-table
# 2. Production operations (maintenance + backup + migration)
ssh user@prod-server "
# Enable maintenance page
wp maintenance-mode activate --path='/var/www/your-site'
# Backup current production DB
wp db export /tmp/prod-backup-$(date +%Y%m%d-%H%M).sql
# Import staging data
wp db import /tmp/staging-final.sql
# URL replacement
wp search-replace 'https://staging.your-site.com' 'https://your-site.com' --precise
# Disable maintenance mode
wp maintenance-mode deactivate
"
echo "✅ Production updated successfully"
wp-config.php Separation (Critical Security Step)
**Never commit wp-config.php with passwords in it**. I use a three-file separation strategy:
# Local-only config (in .gitignore)
cat > wp-config-local.php << 'EOF'
wp-config.php << 'EOF'
WordPress Configuration Entry Point
*/
define('WP_ENV', 'production');
define('AUTH_KEY', 'put your unique phrase here');
define('SECURE_AUTH_KEY', 'put your unique phrase here');
define('LOGGED_IN_KEY', 'put your unique phrase here');
define('NONCE_KEY', 'put your unique phrase here');
/**
* Environment-specific config (NOT in version control)
*/
if (file_exists(__DIR__ . '/wp-config-local.php')) {
require_once __DIR__ . '/wp-config-local.php';
} elseif (file_exists(__DIR__ . '/wp-config-prod.php')) {
require_once __DIR__ . '/wp-config-prod.php';
}
/* Core constants */
define('WP_CONTENT_DIR', __DIR__ . '/wp-content');
define('ABSPATH', __DIR__ . '/');
EOF
# On production server, create wp-config-prod.php separately (via SSH)
ssh user@prod-server "cat > /var/www/your-site/wp-config-prod.php << 'EOF'
> **Security tip**: Store production DB passwords via ANSIBLE_VAULT or SOPS, never in plaintext server configs.
Troubleshooting: Three Real Errors and Fixes
Error 1: "Duplicate entry for key 'PRIMARY'" on wp db import
**Cause**: --add-drop-table was missing during export, or existing data caused primary key conflicts on import.
**Fix**: The correct export command is wp db export --add-drop-table. For import:
wp db drop --yes && wp db create && wp db import /tmp/staging-final.sql
If the issue persists, check that the SQL file header contains DROP TABLE IF EXISTS statements.
Error 2: 502 Bad Gateway after post-receive runs
Cause: PHP-FPM's OPcache wasn't flushed. Old code was cached, but nginx connected to a new PHP process.
Fix: Add this to your post-receive hook:
systemctl reload php8.2-fpm
Or if you're using Docker:
docker exec your-php-container php-fpm -R
Error 3: Images broken after wp search-replace
**Cause**: Image URLs in wp_postmeta are stored in serialized data. A raw string replace can corrupt the serialization string length.
**Fix**: WordPress 5.1+ wp search-replace handles serialized data natively, but if you're using legacy migration plugins:
# Use WP-CLI native command, correctly handles serialized data
wp search-replace 'https://old.com' 'https://new.com' --precise --recurse-queries
One-Command Rollback
# Code rollback to previous Git commit
wp maintenance-mode activate
git reset --hard HEAD~1
git checkout -f
wp maintenance-mode deactivate
wp cache flush
# Or restore from database backup
wp db import /tmp/prod-backup-YYYYMMDD-HHMM.sql
wp cache flush
Post-Deploy Verification Checklist
# WP-CLI doctor check
wp doctor check --path=/var/www/your-site
# Core file integrity
wp core verify-checksums --path=/var/www/your-site
# Plugin and theme status
wp plugin status --path=/var/www/your-site
wp theme status --path=/var/www/your-site
# Verify site URL is correct
wp option get blogname
wp option get siteurl
Summary
The entire pipeline's core tool is WP-CLI. Database operations, cache flushing, maintenance mode — all standardized through it. I wrapped the whole workflow into a deploy.sh script: git push triggers the post-receive hook and everything else is automatic. WordPress 6.7+ WP_CLI_REMOTE_CMD combined with Git Hooks is the most practical lightweight CI/CD alternative for 2026 — no Jenkins, no GitHub Actions, just an SSH-accessible server.
Related Reading
👉 Join MiniMax Token Plan: AI coding acceleration for businesses
👉 Join Zhipu Coding Plan: GLM-4.6/GLM-5 coding packages, China-stable, pay-per-token unlimited
👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation
📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews
🔗 Recommended Tools
These are carefully selected tools. Using our affiliate links supports us to keep producing quality content: