From Headless WordPress to Production with GitHub Actions
WordPress is where I edit the content. Gatsby turns that content into a static website. GitHub Actions builds it, checks the result, and deploys a new release to production.
That sounds neat when compressed into three sentences. The interesting part is everything required to make those sentences reliably true.
I wanted a publishing process that did not depend on building the website on my laptop, manually uploading the public directory, or hoping I had not missed a file. I also did not want every small WordPress edit to trigger another build automatically. Sometimes I update several pages before I am ready to publish anything.
So I built a controlled pipeline around one clear action: save the content in WordPress, then press Send changes live when the batch is ready.
This article explains how that system works, why I made the less obvious decisions, and where I would improve it next.
First, what does “headless WordPress” mean here?
In this project, WordPress is the content management system, but it does not render the public website.
Editors still use the familiar WordPress admin area to manage posts, portfolio cases, taxonomies, images, and other content. WPGraphQL exposes that content through an API. Gatsby reads the data during its build process and generates the static HTML, JavaScript, page data, and processed assets that visitors receive.
Gatsby describes this as sourcing WordPress data through gatsby-source-wordpress and WPGraphQL, then using that data to create static pages during the build. The build places the finished files in the generated public directory. Gatsby's WordPress sourcing guide and build-process documentation cover the underlying mechanism.
The important distinction is this:
I am not deploying WordPress through GitHub Actions. I am using WordPress as the content source, then building and deploying a separate static Gatsby frontend.
That separation improves frontend flexibility and reduces the amount of dynamic application code exposed on the public site. It also creates a new responsibility: content changes do not appear on production until the frontend is rebuilt.
That is the problem the pipeline solves.
The architecture at a glance
The publishing flow looks like this:
WordPress admin
↓
Custom “Send changes live” action
↓
GitHub repository_dispatch event
↓
GitHub Actions production build
↓
Gatsby fetches current WordPress data through WPGraphQL
↓
Generated public/ directory is validated and saved as an artifact
↓
Artifact is uploaded to a new release directory over SSH
↓
Production symlink switches to the new release
↓
xenbel.com serves the new static build
Code changes follow a similar route. A push to the main branch starts the same workflow, and I can also trigger it manually from GitHub Actions.
The result is one deployment path instead of separate procedures for code and content.
Why I chose an explicit publish button
The first version reacted automatically when published WordPress content changed. It queued a build after edits to posts, portfolio cases, relevant taxonomies, attachments, or author profiles.
Technically, it worked. Editorially, it was too eager.
A single content update often involves several saves: correcting copy, replacing an image, editing metadata, checking a taxonomy, and then correcting the typo I noticed immediately after saving. Triggering a complete static build for every step wastes build minutes and can publish a half-finished batch of changes.
I replaced the automatic trigger with a small custom WordPress plugin that separates saving from publishing.
When relevant content changes, the plugin records that there are unpublished updates. It does not make an external request. An administrator can then open the publishing screen, review the status, and press Send changes live once everything is ready.
This is not a complex editorial approval system. It is a deliberately small control that matches the way I maintain the site.
It also gives the person editing WordPress a clear mental model:
- Save all content changes.
- Open the publishing screen.
- Send the batch to production.
- Follow the build in GitHub Actions.
You do not need a terminal or a manual file upload. More importantly, you always know whether pressing Update in WordPress changes the public site.
How WordPress starts the GitHub Actions workflow
The custom plugin sends a POST request to GitHub's repository dispatch endpoint with a specific event type:
$response = wp_remote_post(
'https://api.github.com/repos/OWNER/REPOSITORY/dispatches',
array(
'headers' => array(
'Authorization' => 'Bearer ' . XENBEL_GITHUB_TOKEN,
'Accept' => 'application/vnd.github+json',
'Content-Type' => 'application/json',
),
'body' => wp_json_encode(array(
'event_type' => 'wordpress_content_changed',
)),
)
);
GitHub Actions supports events originating outside GitHub through repository_dispatch, alongside repository events and manual triggers. GitHub's workflow documentation explains those trigger types.
The workflow listens for three events:
on:
workflow_dispatch:
repository_dispatch:
types: [wordpress_content_changed]
push:
branches: [main]
This gives me three useful entry points:
- WordPress content can request a build.
- A code push can build and publish a new version.
- I can run the pipeline manually when diagnosing or recovering from a problem.
The publish action has its own guardrails
The WordPress endpoint does more than display a button.
It:
- accepts POST requests only;
- requires the
manage_optionscapability; - verifies a WordPress nonce;
- keeps the GitHub token out of the plugin code;
- prevents accidental duplicate requests with a short lock;
- records the last requested content revision;
- reports whether GitHub accepted the request;
- links directly to the workflow run history.
The capability check and nonce solve different problems. current_user_can() checks authorization, while check_admin_referer() verifies intent through the nonce. WordPress explicitly recommends using the nonce check together with a capability check rather than treating it as authorization. WordPress nonce documentation
There is another small but important wording choice in the admin notice: GitHub accepted the request does not mean the website was successfully deployed.
The build might still fail. The upload might fail. Production remains unchanged until the entire required path succeeds.
Building the production site in GitHub Actions
The build job runs on a clean Ubuntu runner. It checks out the repository, configures the required Node version, installs the locked dependencies with npm ci, and runs the Gatsby production build.
The simplified sequence is:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps --no-audit --no-fund
- name: Build production site
run: npm run build
- name: Check build output
run: |
test -s public/index.html
test -s public/robots.txt
During the build, Gatsby contacts WordPress, downloads the current published content, executes the page queries, processes assets, and generates the static site.
This means the workflow does not deploy a content snapshot attached to the button click. It builds the latest published WordPress state available when the build runs.
For this website, that is the desired behaviour. For a larger editorial team with overlapping releases, I would introduce immutable content revisions or a dedicated preview-and-approval layer instead.
What is actually validated before deployment?
I prefer being precise about this because “tested pipeline” can mean almost anything.
The current CI workflow performs three meaningful validations:
- Dependency installation must succeed. A missing or incompatible production dependency stops the workflow.
- The Gatsby production build must complete. This catches failures in configuration, GraphQL queries, page creation, server-side rendering, imports, and asset processing.
- Required output files must exist and be non-empty. The workflow checks the generated homepage and
robots.txtbefore saving the build.
I also use targeted Node tests for individual behaviours during development. At the moment, the deployment workflow does not run those separate tests. So I describe the current setup as automated build validation, not comprehensive automated test coverage.
The next improvement is straightforward: add a dedicated test command before gatsby build and make deployment depend on both stages.
For example:
- name: Run automated tests
run: npm test
- name: Build production site
run: npm run build
I would also add a post-deployment HTTP health check and an automatic rollback if the newly activated release does not return the expected response.
Why I save the build as an artifact
After validation, the workflow uploads the generated public directory as a GitHub Actions artifact and keeps it for seven days.
The deployment job downloads that exact artifact instead of rebuilding the site. GitHub describes artifacts as a way to persist workflow output and pass it between jobs. GitHub Actions artifact documentation
That gives the pipeline an important property:
The files that passed the build job are the files sent to the server.
Without the artifact, rebuilding during deployment could fetch newer WordPress content or produce a subtly different output. Passing the same generated directory forward keeps the boundary between build and deploy clear.
Deploying without replacing the live directory in place
Uploading directly into public_html is tempting. It is also how visitors end up receiving a mixture of old and new files while an upload is still running.
Instead, each workflow run gets a separate release directory:
/releases/<workflow-run-id>-<attempt>/
The deployment job:
- creates the new release directory;
- uploads the complete artifact with
rsyncover SSH; - verifies that essential files exist on the server;
- preserves required server files such as
.htaccessand.well-knowndata; - prepares a temporary symlink to the new release;
- switches
public_htmlto that release only after the earlier steps succeed.
The build and deploy jobs are separate, and deploy depends on build:
deploy:
needs: build
If dependency installation, the Gatsby build, output validation, or artifact creation fails, the deployment job does not run.
On the first automated release, the activation script keeps the original manually uploaded public_html directory as a backup. Later deployments replace the symlink, which makes switching releases much cleaner than copying files over the active site.
Preserving older hashed assets
Static frontends commonly reference hashed JavaScript, CSS, page-data, and image files. A visitor may have an older page open while a new version goes live. If the old hashed assets disappear immediately, their next navigation or lazy-loaded resource can fail.
The activation script therefore carries forward existing static, page-data, and top-level generated asset files when they are not already part of the new release.
It is a pragmatic compatibility measure. It also means release storage needs monitoring and old releases need a deliberate cleanup policy.
Deployment security decisions
The pipeline has two separate trust boundaries: WordPress to GitHub, and GitHub to the production server.
WordPress to GitHub
The GitHub token lives in the server configuration rather than the repository or plugin archive. I restrict it to the required repository and permission scope.
Only an authorized WordPress administrator can submit the publish action, and the request must pass the nonce check.
GitHub to the server
The deployment reads its SSH key from GitHub Actions secrets. A separate secret contains the server's verified host keys, and SSH enforces strict host-key checking.
I did not disable verification to make the workflow easier. That would make the connection easier to intercept too.
Only the connection step receives the required GitHub Actions secrets. GitHub encrypts stored secrets and exposes them to a workflow only when referenced. GitHub Actions secrets documentation
The workflow itself has read-only repository content permission:
permissions:
contents: read
Where the hosting environment allows it, I also recommend limiting the deployment credential on the server side.
Preventing overlapping deployments
Both content changes and code pushes can start the workflow. Without coordination, two runs could upload and activate releases at nearly the same time.
The workflow uses a concurrency group:
concurrency:
group: xenbel-site-build
cancel-in-progress: false
This keeps active deployments from running over one another. I chose not to cancel an in-progress build because a content release should not disappear halfway through simply because another request arrived.
GitHub Actions provides concurrency controls specifically for cases such as preventing multiple deployments from executing at once. GitHub concurrency documentation
For a higher-volume publishing system, I would review the exact queue behaviour and introduce explicit release ordering rather than relying only on a shared concurrency group.
What happens when something fails?
A useful deployment pipeline must handle failure as carefully as it handles the happy path.
Here is what the current system does:
| Failure | Result |
|---|---|
| WordPress cannot contact GitHub | The admin receives an error and can retry; the failed request does not change production. |
| Dependency installation fails | The build job stops; deploy never starts. |
| Gatsby cannot fetch or build the content | The build job fails; deploy never starts. |
| Required output files are missing | The workflow rejects the artifact before deployment. |
| SSH secrets are missing | The deploy job fails before connecting. |
| Upload fails | The script leaves the incomplete release inactive. |
| Release validation fails on the server | public_html keeps pointing to the previous release. |
| First activation fails during switching | The script restores the original manually uploaded directory. |
There are still limitations:
- the workflow does not yet run the project's complete separate test suite;
- there is no automated browser smoke test after activation;
- there is no automatic rollback based on an HTTP health check;
- the workflow does not delete old release directories automatically;
- the WordPress screen confirms dispatch, not final production success;
- the build uses the latest published WordPress content at execution time, not an immutable revision.
Those are not reasons to pretend the pipeline is unreliable. They are the boundaries of the current implementation—and the roadmap for making it stronger.
What changed in day-to-day work
Before this pipeline, publishing the Gatsby frontend meant building locally and moving generated files manually. That process depended on the state of one computer and mixed several responsibilities into one manual operation.
Now:
- content remains manageable in WordPress;
- editors can group several changes into one release;
- code and content use the same build path;
- every run has a visible log in GitHub Actions;
- failed builds do not overwrite the live website;
- the exact successful artifact moves from build to deploy;
- previous releases remain available for manual rollback;
- production credentials stay outside the repository.
The biggest improvement is not that the deployment is “automatic.” It is that the process is explicit and repeatable.
Automation is useful when it removes fragile repetition while keeping the important decisions visible. In this case, WordPress still owns the editorial decision to publish. GitHub Actions owns the repeatable technical work that follows.
Would I use this architecture for every WordPress website?
No.
A traditional WordPress frontend is often the simpler and better choice. Headless architecture introduces build infrastructure, API dependencies, preview considerations, cache behaviour, and a different deployment model. Those costs need to solve a real problem.
This approach is a good fit when:
- the frontend needs to be developed independently from the WordPress theme layer;
- most public content can be generated statically;
- performance and controlled releases matter;
- the team is comfortable monitoring builds;
- WordPress remains valuable as an editing interface;
- the project benefits from a React-based frontend or multiple content consumers.
It is probably unnecessary when a conventional WordPress build already meets the project's editorial, performance, and maintenance needs.
I like headless WordPress. I just do not treat it as a personality trait.
The main lesson
Connecting WordPress to GitHub Actions is the easy part. A single API request can start a workflow.
The real engineering work is deciding:
- what counts as a publish action;
- which user is allowed to trigger it;
- what must pass before production can change;
- whether deployment uses the same artifact that was validated;
- how incomplete uploads are kept away from visitors;
- how concurrent releases are handled;
- what remains available when a release fails;
- and how honestly the admin interface communicates status.
That is what turns a webhook and a YAML file into a deployment pipeline people can actually rely on.



