Editing themes over FTP, or installing plugins straight on production, works until it doesn’t: there’s no history, no review, no rollback, and staging and live drift apart. Putting WordPress code in Git and deploying through a pipeline brings the same discipline as any other web app. The trick is knowing what belongs in Git (code) and what doesn’t (uploads and the database).

What goes in Git, and what doesn’t

In Git Not in Git
Custom theme(s), child theme wp-content/uploads/ (user content)
Custom plugins and mu-plugins Database (content, settings)
composer.json / composer.lock listing third-party plugins wp-config.php secrets (use environment variables or a non-versioned file)
Deployment and config templates Cache directories, node_modules, build output (if built in CI)

Third-party plugins and themes can either be committed (simple) or managed with Composer (cleaner, reproducible).

Option A: track only wp-content (simple)

Keep WordPress core managed by WP-CLI on the server, and version your wp-content:

# .gitignore at wp-content/
uploads/
cache/
upgrade/
*.log

The repository contains themes/your-theme, plugins/your-plugin, and optionally the third-party plugins you rely on.

Bedrock (by Roots) restructures WordPress for modern workflows: WordPress core and plugins installed via Composer (from WPackagist, a mirror of the wordpress.org plugin directory), config in .env files, and a web root in web/.

composer create-project roots/bedrock mysite
cd mysite
composer require wpackagist-plugin/wordpress-seo wpackagist-theme/twentytwentyfour

composer.lock pins exact versions, so every environment installs the same code. Plugin updates become Git commits you can review and roll back. Premium plugins can be added through private Composer repositories or committed separately.

See Composer.

Deploy with GitHub Actions and rsync

A typical pipeline: build assets (theme CSS/JS) in CI, then sync to the server:

name: Deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.3' }
      - run: composer install --no-dev --optimize-autoloader
      - run: cd web/app/themes/mytheme && npm ci && npm run build
      - name: Rsync to server
        env:
          SSH_KEY: ${{ secrets.DEPLOY_KEY }}
        run: |
          mkdir -p ~/.ssh && echo "$SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H ${{ secrets.HOST }} >> ~/.ssh/known_hosts
          rsync -az --delete \
            --exclude='web/app/uploads/' --exclude='.env' --exclude='.git/' \
            ./ deploy@${{ secrets.HOST }}:/var/www/example.com/
          ssh deploy@${{ secrets.HOST }} 'cd /var/www/example.com && wp cache flush && sudo systemctl reload php8.3-fpm'

Key points:

  • Exclude uploads and .env from --delete, or production content gets wiped.
  • Reloading PHP-FPM (or resetting OPcache) makes PHP pick up the new code.
  • For zero-downtime, deploy into release directories and switch a current symlink (as Deployer or Capistrano-style scripts do). See zero-downtime deploys for the pattern.

See GitHub Actions deploy to a VPS and rsync.

The database and uploads

Code flows up (dev → staging → production) through Git. Content flows down (production → staging → dev):

# pull production DB to staging, replacing URLs
wp db export - [email protected]/var/www/example.com | wp db import -
wp search-replace 'https://example.com' 'https://staging.example.com' --all-tables
rsync -az [email protected]:/var/www/example.com/web/app/uploads/ ./web/app/uploads/

Configuration that lives in the database (theme options, plugin settings, ACF field groups) is the hard part. Options:

  • export ACF field groups to JSON (ACF supports acf-json in the theme), so they’re versioned;
  • use WP-CLI commands in deploy scripts for settings that must be identical;
  • document manual steps in the pull request.

See WordPress staging site and WP-CLI.

Lock down production

Once deployments go through Git, prevent changes on the server:

// wp-config.php (production)
define('DISALLOW_FILE_EDIT', true);   // no theme/plugin editor
define('DISALLOW_FILE_MODS', true);   // no plugin/theme installs or updates from the dashboard

Updates then come through the pipeline, after testing on staging. Keep security updates flowing quickly. See updating WordPress safely.

Quick reference

Task Tool / setting
Version third-party plugins Composer + WPackagist
Modern structure Bedrock
Deploy CI → rsync / Deployer, exclude uploads and .env
Sync content down wp db export / wp search-replace / rsync uploads
Block edits on prod DISALLOW_FILE_EDIT, DISALLOW_FILE_MODS

Related: WordPress on a VPS and WordPress hardening.

  • WordPress
  • Git
  • CI/CD