← Back to Home

WordPress Deployment with WP-CLI + Git Hooks

WordPressWP-CLIGit HooksDevOpsDeployment

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

> **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:

☁️ DigitalOcean Cloud ⚡ Vultr VPS ⭐ MiniMax Token Plan 🧩 Zhipu Coding Plan 🎁 Zhipu 20M Tokens Gift 🤖 QoderWork CN (Refer & Earn) ☁️ Aliyun AI Products 📚 WordPress Books 🔍 WordPress SEO Books 🌐 Web Hosting Books 🐳 Docker Books 🐧 Linux Books 🐍 Python Books 💰 Affiliate Marketing 💵 Passive Income Books 🖥️ Server Books ☁️ Cloud Computing Books 🚀 DevOps Books
← Back to Home