DevOps & Hosting

How to deploy a WordPress theme with GitHub Actions — tests first

A safe CI/CD pipeline for WordPress themes: every push runs checks on two PHP versions, and only passing code on main is deployed to the VPS — with a deploy key that can write to one folder and nothing else.

GitHub Actions – How to deploy a WordPress theme with GitHub Actions — tests first

The safest way to deploy a WordPress theme is a two-job GitHub Actions workflow: a test job that checks every PHP and JSON file on every push, and a deploy job that runs only when the tests pass on the main branch and copies the theme to the server with rsync over SSH. The SSH key belongs to a restricted deploy user that can write to the theme folder and nothing else.

I use this GitHub Actions pipeline on client themes and on my own site. It replaced FTP uploads completely — and with them, the “it worked on my machine” surprises on live sites.

Key takeaways

  • Keep the theme in a private GitHub repository; never edit theme files on the live server.
  • Job 1 – test: PHP syntax check on the oldest and newest PHP versions you support, plus validation of ACF JSON field groups.
  • Job 2 – deploy: depends on the test job and runs only on main; pull requests are tested but never deployed.
  • Use a separate deploy user whose SSH key is locked with rrsync to a single folder, write-only.
  • Finish with a smoke test that fails the run if the site does not answer correctly.
GitHub Actions guide overview: what this article covers
What this guide covers

Why stop deploying WordPress with FTP?

FTP deploys have three problems:

  1. No checks. One missing semicolon and the whole site shows a white screen.
  2. No history. Nobody knows who changed what, or how to go back.
  3. Too much access. FTP logins often reach the whole server, and passwords get shared in emails.

A Git-based pipeline fixes all three: every change is tested, every version is recorded, and the server only accepts files from one restricted key.

What does the GitHub Actions pipeline look like?

StageRuns whenWhat it doesIf it fails
Test (PHP 8.1)Every push and pull requestphp -l on every PHP file, JSON validationNothing is deployed
Test (PHP 8.5)Every push and pull requestSame checks on the server’s PHP versionNothing is deployed
DeployPush to main, tests passedrsync theme to the VPSSite keeps the previous version
Smoke testAfter deployRequests the site and checks the statusRun is marked failed; you are alerted

Step 1: How should the theme repository be set up?

Put only the theme in the repository — not WordPress core, not uploads, not other plugins. A typical structure:

  • style.css, functions.php and templates
  • inc/ for PHP modules (blocks, helpers, security)
  • template-parts/blocks/ for ACF block templates
  • acf-json/ for ACF field groups — so fields are versioned with the code
  • assets/ for CSS, JS, fonts and images
  • .github/workflows/deploy.yml for the GitHub Actions pipeline

Add a .gitignore for node_modules, logs and .env files. Secrets never go into the repository.

GitHub Actions at a glance: comparison table
At a glance

Step 2: How do you write the test job?

The test job uses a matrix to run the same checks on two PHP versions: the oldest version your theme supports and the version your server runs. That catches syntax that is too new for old servers and deprecations on new ones.

The checks I run on every theme:

  1. PHP syntax: php -l on every .php file. It takes seconds and catches the errors that cause white screens.
  2. ACF JSON: every file in acf-json/ must be valid JSON. A broken field group silently removes fields from the editor.
  3. Theme header: style.css must contain Theme Name:, or WordPress will not recognise the theme.

You can add more over time: PHP_CodeSniffer with WordPress coding standards, a CSS linter, or a build step for Sass and JavaScript.

Step 3: How do you create a restricted deploy user on the server?

This is the step most tutorials skip, and the most important for security.

  1. Create a user called deploy with no sudo rights.
  2. Make it own the theme folder (group www-data so the web server can read it).
  3. Generate a new SSH key pair just for GitHub Actions. Do not reuse your personal key.
  4. Add the public key to /home/deploy/.ssh/authorized_keys with a forced command:

command="/usr/bin/rrsync -wo /var/www/site/wp-content/themes/mytheme",restrict ssh-ed25519 AAAA… github-actions

rrsync only allows rsync, only inside that folder, and -wo makes it write-only. restrict disables port forwarding, terminals and everything else. If the key ever leaked, it could not open a shell or read your wp-config.php.

  1. Test it: an SSH login with that key should be refused with an rrsync message, not a shell.

Step 4: Which secrets does GitHub need?

In the repository settings, add these Actions secrets:

SecretValue
DEPLOY_SSH_KEYThe private key of the deploy key pair
DEPLOY_KNOWN_HOSTSThe server’s host key (from ssh-keyscan)
DEPLOY_HOSTThe server IP or hostname
DEPLOY_USERdeploy

Pinning the host key in known_hosts protects the deploy from a man-in-the-middle attack. Delete the private key from your computer once it is stored in GitHub.

Step 5: How do you write the deploy job?

The deploy job:

  1. Depends on the test job (needs: test), so it never runs if a test fails.
  2. Runs only for pushes to main, not for pull requests.
  3. Uses a concurrency group, so two deploys never run at the same time.
  4. Writes the key and known hosts to ~/.ssh with strict permissions.
  5. Runs rsync -az --delete from the repository to the server, excluding .git, .github and anything else that should not be public. Because rrsync limits the key to the theme folder, the destination is simply /.
  6. Sets permissions during the copy (--chmod=D755,F644).

--delete removes files on the server that you deleted in Git, so the server always matches the repository exactly.

Step 6: How do you add a smoke test?

After the copy, request two URLs:

  • The theme’s style.css — must return 200, proving the files arrived.
  • The home page — must return 200 (or 503 if you are deliberately in maintenance mode).

If either check fails, the GitHub Actions workflow fails and GitHub emails you. You know within a minute, instead of hearing it from a client.

What problems might you hit?

“Connection timed out” on port 22

The server’s firewall allows SSH, but GitHub’s runners still cannot connect. Many hosting providers have an extra firewall in their control panel that only allows certain IPs. Allow port 22 there, or use a self-hosted runner on the server.

“Permission denied (publickey)”

The key in GitHub does not match authorized_keys, or the deploy user’s .ssh folder has the wrong permissions (700 for the folder, 600 for the file).

Files deployed but the site did not change

Check caching: a page cache, CDN or OPcache may serve the old version. Version your CSS and JS URLs with the file modification time so browsers fetch new files.

Line endings differ between Windows and the server

If you develop on Windows, Git may convert line endings in your working copy. The server receives the committed version, which is what you want. Compare against git show HEAD:file, not your local file.

How does this fit into a daily workflow?

  1. Make changes on your local WordPress (LocalWP works well).
  2. Commit to a branch and open a pull request — the tests run automatically.
  3. Merge to main when the checks are green.
  4. GitHub deploys within a minute; the smoke test confirms the site is up.
  5. To roll back, revert the commit — the previous version deploys automatically.

How should you handle branches and releases?

A simple branching model is enough for most WordPress themes:

BranchPurposeDeploys?
mainExactly what is liveYes, automatically
feature/...One change or featureNo — tested only
hotfix/...Urgent fixNo — merged quickly into main

Work on a feature branch, open a pull request, and let the tests run. Merge only when they are green. Because main always matches the live site, you always know what is deployed.

For bigger releases, tag versions (v1.4.0) and bump the Version in style.css. Your asset URLs can include the file modification time, so browsers download the new CSS and JavaScript immediately.

Can you add a staging environment to the same pipeline?

Yes, and it is worth it for client sites. Create a staging subdomain on the same VPS with its own WordPress install and its own restricted deploy key. Then:

  • Pushes to a staging branch deploy to the staging site.
  • Pushes to main deploy to production.

The client can review changes on staging before they go live, and the production deploy is just a merge.

What about themes that need a build step?

If your theme uses Sass, a JavaScript bundler or Tailwind, add a build step between the tests and the deploy:

  1. Install Node.js in the GitHub Actions workflow.
  2. Run npm ci and your build command.
  3. Deploy the built files, and exclude source folders and node_modules.

Commit the lock file so builds are repeatable, and never commit built files and source files that disagree.

How do you roll back a bad deploy?

Because every deploy is a commit, rolling back is just another commit:

  1. Find the commit that caused the problem.
  2. Use git revert to create a commit that undoes it.
  3. Push to main — GitHub Actions tests and deploys the previous version within a minute.

No FTP, no guessing which files changed, and the history shows exactly what happened and why.

Which security checks are worth adding later?

Once the basics work, these checks add real value:

  • PHP_CodeSniffer with WordPress Coding Standards, focused on escaping and sanitising.
  • A secrets scanner, so API keys never get committed by accident.
  • Dependency review if the theme uses Composer or npm packages.
  • A check that no debug code (var_dump, console.log) reaches production.
GitHub Actions summary: key takeaways
Summary of the key points

FAQ

Frequently asked questions

Do I need GitHub Actions for a small WordPress site?

Not strictly, but the setup takes an hour or two and removes most deployment risk. For any site that earns money, it pays for itself the first time it blocks a broken deploy.

Can GitHub Actions deploy plugins and the database too?

It can deploy custom plugins the same way as themes. Database content (pages, posts, settings) should stay on the live site; move it with migration tools only when needed.

Is it safe to store an SSH key in GitHub Secrets?

Yes, when the key belongs to a restricted user that can only write to one folder. Never store a root key or a key with shell access.

What if my host does not allow SSH?

Then the host is limiting how professionally you can deploy. Many managed WordPress hosts offer Git deploys; otherwise a small VPS is worth considering.

Can this work with Bitbucket or GitLab?

Yes. The same idea — test job, deploy job, restricted key — works with Bitbucket Pipelines and GitLab CI.

If you want this GitHub Actions pipeline set up for your WordPress theme or plugin, see my VPS and DevOps service or hire me on Upwork.

Planning a project?

Invite me to your job or hire me on Upwork, and we can talk it through there. I usually reply within a few hours.

Hire me on Upwork
Shah Kamal

Written by

Shah Kamal

WordPress developer with 12+ years and 112 Upwork jobs. I build fast, secure WordPress sites and run them on well-tuned servers.

Insights

Keep reading

All insights

Have a WordPress project in mind?

Tell me what you need — I usually reply within a few hours.