
How to Deploy WordPress with Git and a CI/CD Pipeline?
A CI/CD pipeline replaces manually uploading changed files over FTP with a repeatable, automated process that runs the same way for every deploy, whether that's syncing a theme's changed files or running database migrations. "CI" (continuous integration) means every push gets checked automatically, linting, tests, whatever your project needs; "CD" (continuous deployment) means a passing push to the right branch gets shipped to a server automatically, without anyone needing to remember the manual steps.
For a typical WordPress project, the parts worth version-controlling and deploying through a pipeline are your theme and plugin code, not WordPress core itself (which the server or a tool like WP-CLI manages independently) and not the uploads directory or database (which live on the server and get backed up separately, not redeployed from Git).
This guide covers structuring a WordPress repository for deployment, a GitHub Actions workflow that deploys over rsync/SSH on push to main, a variant using WP-CLI directly on the remote server, handling secrets safely, and deploying to a staging environment before production.
Step 1: Decide What Gets Version-Controlled
A sensible .gitignore for a WordPress project keeps the repository focused on code you actually author, not WordPress core or user-generated content:
# .gitignore
wp-admin/
wp-includes/
wp-*.php
wp-config.php
wp-content/uploads/
wp-content/upgrade/
wp-content/cache/
*.log
.env
Everything under wp-content/themes/your-theme and wp-content/plugins/your-custom-plugin stays tracked, since that's the code your team actually writes and reviews. Third-party plugins pulled from the plugin directory are often better managed with WP-CLI or Composer (via WPackagist) than committed directly, since committing an entire third-party plugin's source bloats the repository and makes updates harder to review as diffs.
Step 2: SSH Key Setup for Automated Deploys
The pipeline needs a way to authenticate to your server without a human typing a password. Generate a dedicated deploy key (never reuse a personal SSH key for this):
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""
Add deploy_key.pub's contents to the target server's ~/.ssh/authorized_keys (ideally for a low-privilege deploy user, not root), and keep deploy_key (the private half) out of the repository entirely, it goes into your CI provider's encrypted secrets store instead, covered in the next step.
Step 3: Store Secrets in GitHub Actions
In the repository's Settings → Secrets and variables → Actions, add:
DEPLOY_SSH_KEY— the private key generated above.DEPLOY_HOST— the server's hostname or IP.DEPLOY_USER— the SSH user to connect as.DEPLOY_PATH— the absolute path to the theme/plugin directory on the server.
Secrets stored this way are encrypted at rest, masked in workflow logs automatically if they're ever accidentally printed, and never appear in the repository's code or history, unlike credentials hardcoded directly into a workflow file.
Step 4: A GitHub Actions Workflow Using rsync over SSH
.github/workflows/deploy.yml, triggered on every push to main:
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
- name: Install Composer dependencies
run: composer install --no-dev --optimize-autoloader
working-directory: wp-content/themes/my-theme
- name: Install Node dependencies and build theme assets
run: |
npm ci
npm run build
working-directory: wp-content/themes/my-theme
- name: Set up SSH key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Add server to known_hosts
run: ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
- name: Deploy via rsync
run: |
rsync -avz --delete \
--exclude='.git' \
--exclude='node_modules' \
--exclude='.github' \
wp-content/themes/my-theme/ \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/
A few decisions worth calling out:
- Building assets (
npm run build) inside the CI runner, not on the production server, keeps Node and its dependencies entirely out of the production environment, and means the exact same compiled output that passed CI is what gets deployed, rather than a fresh build running on the server itself. rsync --deleteremoves files on the server that no longer exist in the source, keeping the deployed theme an exact mirror of the repository rather than slowly accumulating stale files from removed features. Omit--deleteif you deliberately want the server directory to only ever gain files, never lose them, though this is rarely what you actually want for a theme directory.--exclude='node_modules'keeps the (large, and unnecessary on the server)node_modulesfolder from ever being synced, since only the built output (typically adistorbuildfolder inside the theme) needs to reach production.
Step 5: A Workflow Using WP-CLI for Post-Deploy Steps
File sync alone doesn't run database migrations or flush caches. A fuller pipeline runs WP-CLI commands over SSH immediately after the file sync:
- name: Deploy via rsync
run: |
rsync -avz --delete \
wp-content/themes/my-theme/ \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/
- name: Run post-deploy tasks
run: |
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} '
cd /var/www/html &&
wp cache flush &&
wp core update-db &&
wp plugin update --all
'
Wrapping the remote commands in a single ssh user@host '...' invocation, rather than several separate ssh calls, matters because each ssh connection is a fresh shell; a cd in one ssh call doesn't persist into the next one, so any commands depending on the working directory need to run inside the same remote shell session.
Step 6: Deploy to Staging Before Production
A safer pipeline deploys every push to a staging branch onto a staging site automatically, and only promotes to production on a push (or merge) to main, giving you a real environment to check changes in before they're live:
name: Deploy
on:
push:
branches: [main, staging]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set deployment target
id: target
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "host=${{ secrets.PROD_HOST }}" >> "$GITHUB_OUTPUT"
echo "path=${{ secrets.PROD_PATH }}" >> "$GITHUB_OUTPUT"
else
echo "host=${{ secrets.STAGING_HOST }}" >> "$GITHUB_OUTPUT"
echo "path=${{ secrets.STAGING_PATH }}" >> "$GITHUB_OUTPUT"
fi
- name: Set up SSH key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Add server to known_hosts
run: ssh-keyscan -H ${{ steps.target.outputs.host }} >> ~/.ssh/known_hosts
- name: Deploy
run: |
rsync -avz --delete \
wp-content/themes/my-theme/ \
${{ secrets.DEPLOY_USER }}@${{ steps.target.outputs.host }}:${{ steps.target.outputs.path }}/
Branching the target host on github.ref inside a single workflow avoids duplicating the entire deploy job for two near-identical environments; only the destination changes.
Step 7: Roll Back a Bad Deploy
Because deploys are driven by Git, rolling back is a Git operation, not a manual file restore. Reverting the last commit and pushing triggers the same pipeline again, this time syncing the reverted (previous) state:
git revert HEAD
git push origin main
This works cleanly as long as the pipeline's rsync --delete step is genuinely idempotent, running it twice with the same source state always produces the same result on the server, which is one more reason to prefer rsync mirroring the repository state exactly over an append-only deploy strategy that could accumulate drift.
Step 8: A Simpler Alternative — git pull on the Server
For a low-traffic site or solo developer project, a full CI runner may be more infrastructure than the situation calls for. A post-receive Git hook on the server itself is a lighter-weight version of the same idea:
#!/bin/bash
# .git/hooks/post-receive on the server's bare repository
TARGET="/var/www/html/wp-content/themes/my-theme"
GIT_DIR="/var/repo/my-theme.git"
git --work-tree="$TARGET" --git-dir="$GIT_DIR" checkout -f main
cd "$TARGET" && npm ci && npm run build
wp cache flush --path=/var/www/html
Pushing to this remote (git push production main) triggers the checkout and build directly on the server. It's simpler to set up than GitHub Actions, but runs the build on the production server itself and offers none of CI's isolated, disposable runner environment or pre-deploy checks, worth the tradeoff mainly for smaller projects without a dedicated CI budget.
Step 9: Run Automated Checks Before Deploying
The "CI" half of CI/CD is worth taking seriously even on a small project: catching a PHP syntax error or a failing lint rule in the pipeline, before a broken file ever reaches the server, is far cheaper than debugging a white screen on production afterward. Add a separate job that runs first, and make deployment depend on it:
name: Deploy to Production
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
tools: composer, phpcs
- name: Lint PHP syntax
run: find wp-content/themes/my-theme -name '*.php' -print0 | xargs -0 -n1 php -l
- name: Run PHP CodeSniffer against WordPress coding standards
run: phpcs --standard=WordPress wp-content/themes/my-theme
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ...the rsync deploy steps from Step 4
needs: test is what makes the deploy job wait for test to succeed first, and skip entirely if it fails, so a syntax error or a coding-standards violation blocks the deploy automatically instead of relying on someone remembering to run php -l locally before pushing. php -l alone (a built-in PHP lint check with no dependencies) catches fatal syntax errors; phpcs with the WordPress standard additionally catches unescaped output, missing nonces, and other WordPress-specific issues the shortcode guide's security considerations section covers by hand.
Step 10: Notify the Team on Deploy Success or Failure
A deploy that fails silently is often worse than one that fails loudly, since nobody notices until a user reports something broken. Adding a notification step at the end of the job, gated on the job's outcome, closes that gap:
- name: Notify Slack on failure
if: failure()
run: |
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"Deploy to production failed for commit ${{ github.sha }}.\"}" \
"${{ secrets.SLACK_WEBHOOK_URL }}"
- name: Notify Slack on success
if: success()
run: |
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"Deployed ${{ github.sha }} to production.\"}" \
"${{ secrets.SLACK_WEBHOOK_URL }}"
if: failure() and if: success() are GitHub Actions' built-in status check functions, evaluated against the outcome of every step that ran earlier in the same job, which is what lets a single workflow report both outcomes without needing a separate always-running job just to check exit codes manually.
Frequently Asked Questions (FAQ) About WordPress CI/CD
Generally no. Core is better managed independently, through the hosting platform, WP-CLI, or a tool like Composer with a WordPress-specific installer, so that core security updates aren't tangled up with your theme and plugin release cycle. Your repository should focus on the theme and plugin code you actually author.
These live on the server and are managed with their own backup and sync strategy, typically wp db export/import and a file sync tool (or your host's built-in backup system) rather than the Git-based deploy pipeline, which should only ever touch code, not content or user-uploaded media.
Anything committed to a repository's history is visible to anyone with read access to that repository, including in old commits even after the credential is later removed. GitHub Actions secrets are encrypted, scoped to the repository, and automatically masked in log output, which a hardcoded value in the YAML file itself never is.
Yes, the underlying approach, checkout, build, rsync or WP-CLI over SSH, is identical; only the YAML syntax and the specific name of the secrets/variables mechanism differ between GitLab CI (.gitlab-ci.yml, CI/CD Variables) and Bitbucket Pipelines (bitbucket-pipelines.yml, Repository Variables).
rsync updates files individually rather than atomically, so a very large deploy can theoretically be interrupted mid-sync. For zero-downtime deploys, sync into a fresh timestamped directory on the server and atomically swap a symlink to point at it once the sync completes, a pattern tools like Deployer and Capistrano automate directly.
No, even a solo developer deploying straight to production benefits from CI/CD, since it removes the manual, error-prone step of remembering which files changed and uploading them correctly. A staging environment is a valuable addition once a project has enough real usage that testing changes on a live copy before they reach visitors becomes worth the extra infrastructure.
Conclusion
The mechanics of a WordPress CI/CD pipeline are simpler than they might sound: check out the code, build any assets, and sync the result to a server, either with rsync over SSH or a lighter git pull-based hook. The genuine value isn't the automation itself so much as what it removes, no more remembering which of a dozen changed files need to go up over FTP, and no more deploys that work differently depending on who's running them manually.
Start with the single-branch rsync workflow in Step 4, since it covers the majority of real WordPress deploy needs on its own, and layer in a staging branch from Step 6 and WP-CLI post-deploy steps from Step 5 once the project's size and traffic actually justify the added complexity.
Here are a few additional resources if you want to go deeper:
- GitHub Actions Documentation — the complete workflow syntax and triggers reference.
- GitHub Actions: Encrypted Secrets — how repository secrets are stored and used safely in workflows.
- WP-CLI Documentation — the command reference for WP-CLI steps used in a deploy pipeline.


