My WordPress site deploys itself. I push to master, GitHub Actions lints the PHP, builds the blocks, assembles a complete WordPress installation, syncs it to my server as a new release directory, and flips a symlink. If anything goes wrong, rolling back is repointing one symlink. This post explains the whole pipeline and the specific decisions that make it boring and reliable.
What lives in git
The repository is wp-content, not the whole WordPress installation. Core does not belong in your repo; it is a build artifact you can fetch for any version. The repo holds themes, plugins, mu-plugins, and one small text file called WP-VERSION.txt containing the core version to deploy, currently a single line reading 7.0.2. Updating WordPress core is a one-line commit, and the version that runs in production is always visible in git history.
Plugins are committed as full source rather than managed by Composer. That is a deliberate trade for a personal site: the deploy needs no dependency resolution, the diff for every plugin update is reviewable, and there is no moment where production runs code that was never in a pull request.
The build job
The workflow runs two jobs. The first lints changed PHP files with PHPCS against the WordPress coding standards, and only the changed files, so lint time stays constant as the repo grows. The second job assembles the site:
- name: Download WordPress
run: |
WP_VERSION=$(cat WP-VERSION.txt | tr -d '[:space:]')
mkdir -p /tmp/build
wp core download --version="$WP_VERSION" --path=/tmp/build --allow-root
- name: Build WordPress installation
run: |
rm -rf /tmp/build/wp-content
mkdir -p /tmp/build/wp-content
rsync -a --exclude=".git" "$GITHUB_WORKSPACE/" /tmp/build/wp-content/
cd /tmp/build/wp-content
rm -rf uploads
ln -sn ../../../uploads uploads
The uploads directory is a symlink pointing outside the release, to a shared directory that survives every deploy. User uploads are data, not code, and they never belong inside a release that gets deleted.
Atomic releases with a symlink flip
The server keeps a releases directory with the last five builds and a current symlink that nginx serves from:
RELEASE_PATH="$DEPLOY_PATH/releases/$(date +%Y%m%d%H%M%S)"
rsync -rz --delete --links /tmp/build/ "deploy-server:$RELEASE_PATH/"
ssh deploy-server "ln -sfn '$RELEASE_PATH' '$DEPLOY_PATH/current'"
The rsync happens while the old release is still serving traffic, so slow uploads cost nothing. The switch is a single ln -sfn, which is as close to atomic as a deploy gets. No visitor ever sees a half-copied site. Rollback is the same operation pointed at the previous release directory, and a cleanup step keeps only the five most recent releases so the disk does not grow forever.
The steps everyone forgets: after the symlink
Flipping the symlink is not the end. Three kinds of state still remember the old release, and each needs an explicit step:
- OPcache. PHP caches compiled files by path, and with validate_timestamps tuned down for performance it will happily keep executing the old release. The pipeline calls cachetool opcache:reset against the PHP-FPM socket.
- The object cache drop-in. A fresh release means a fresh wp-content, so the pipeline runs wp redis enable to make sure the object-cache.php drop-in is in place.
- Page caches. The origin’s nginx page cache and the CDN’s edge cache both hold HTML rendered by the old release. The final step purges both: wp nginx-helper purge-all for the origin, and a Cloudflare API call for the edge. I learned to add this step the hard way, after browsers kept receiving a twelve-hour-old homepage while curl swore everything was fine.
Secrets and access
The workflow needs three secrets: the server host, a dedicated SSH deploy key, and the deploy path. Cloudflare credentials are deliberately not GitHub secrets; the purge step reads them on the server from where the Cloudflare plugin already stores them, so rotating the API key is one change in one place.
What I would tell past me
- Release directories plus a symlink beat in-place rsync in every way that matters, and cost about ten extra lines of YAML.
- Write down every cache between PHP and the visitor’s eyeballs, then make the pipeline purge each one. A deploy that does not purge is a deploy that did not happen.
- Keep core out of git but pin its version in a file. “What version is production running” should be answerable from the repo.
- Let plugins own their infrastructure. Calling wp nginx-helper purge-all survives configuration changes that a hardcoded redis-cli command would not.
The whole pipeline is about 180 lines of YAML and deploys in under three minutes. It has made site maintenance feel like software development instead of surgery, which is exactly what a deploy pipeline is for.