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.
Option B: Composer and Bedrock (recommended for teams)
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
.envfrom--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
currentsymlink (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-jsonin 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.