Four websites run on one WordPress multisite on a small cloud machine: angelulz.com, a household documentation site behind a sign-in, merryonmaintx.com for a city Christmas celebration, and a preview of Southern Sparkle Design’s new site. I moved all of their code, and the documentation’s content, into one private Git repository. The server only receives changes from it, and every change has a history and a way back.
The problem
The four sites share one WordPress install, one database and one backup, but their code was split three ways. The documentation’s source lived in a folder on the server, not in Git. Merry on Main’s theme and the small plug-ins that load on every site existed only on the server. Southern Sparkle had a repository of its own. Nothing flagged a file edited on the server by hand, and a docs change had no real rollback.
What I built
- One repository, four sites.
network/holds what every site shares: the must-use plug-ins and a lock file listing each third-party plug-in and theme with its version.sites/has one folder per site. - A deploy-only checkout on the server. It pulls, fast-forward only, and nothing is edited there and copied back.
- A publish guard. The docs publisher refuses to run unless the checkout matches GitHub exactly, so nothing unpushed goes live.
- A read-only drift check.
drift.shcompares the checkout with the live theme and plug-in files and lists any difference. - Docs as code. Every documentation page is a Markdown file that the publisher converts to WordPress blocks. angelulz.com’s pages and posts are published the same way, by a second publisher.
- Guest pages, checked twice. The docs build checks each page’s audience against the paths a guest account may read, and the web server enforces the same list.
- One deploy script for shared code. Merry on Main’s theme and the shared plug-ins go out with a script that checks every PHP file first, copies only what changed, and ends with the drift check.
- Spam checks on the forms. Both form plug-ins ask Akismet about every message: a flagged sign-up waits for an organiser, a flagged message arrives tagged, and either is binned after 30 days.
I imported everything as found, after scanning it for secrets.
How it works

A page edit goes straight to the main branch; a theme, plug-in or script change goes through a pull request. On the server, the checkout is pulled and publish.sh runs. It checks the tree against GitHub, rebuilds the menu, tiles and changelogs from each page’s front matter, writes only the pages that changed, and clears the page cache. It never deletes a page.
The publisher also draws each site’s traffic charts. The home server writes a JSON snapshot of per-site requests and response times every 15 minutes, and the publisher bakes it into charts. If a snapshot can’t be refreshed, the last good file stays, so a chart is stale rather than wrong.
Only one docs job runs at a time. The publisher, the theme deployer and the git pull before them all take the same flock lock. A second run waits up to 900 seconds, then gives up having done nothing. I added it after two publishes overlapped and one stopped twenty pages short without an error.
Decisions and trade-offs
- One repository for the whole multisite, not one per site. I first planned a repository for the docs alone, then widened it the same evening so shared code has one home.
- Southern Sparkle moved in with its history, not kept separate. It came in with
git subtree add. Its long-term hosting is undecided, so it may use shared code only if its own rules file lists it. Leaving stays onegit subtree splitplus copying the listed items. - Sites only, not the server’s setup. Compose, nginx, PHP’s settings, Traefik and the WordPress config file belong with my other infrastructure code, so they live in that repository as another host, credentials outside Git. Both halves are drift-checked.
- Charts baked in at publish time, not live. The charts show the numbers as of the last publish, and say so. I gave up freshness to keep the dashboards admin-only.
What’s next
Southern Sparkle’s cutover, after its owner reviews the preview: its real domain moves to its site here, the business’s mail records first, and search engines are let in.
Built with
WordPress multisite, PHP, Python, Bash, Git and GitHub, Markdown, flock, Akismet, nginx, Traefik, Docker, DigitalOcean.