Fourteen live sites, one Git repo: shared core, isolated databases, rsync without --delete, an SSH ban story, and a CI guard born from 14,616 NUL bytes.
I build jekcms, and I'm also its most demanding user. Fourteen live content sites run on it right now — my own publishing network, not client demos or staging toys. The biggest site holds 2,300+ posts; the fleet as a whole has passed 5,500. All of it ships from a single Git repository to shared LiteSpeed hosting behind Cloudflare.
This is the unpolished version of how that works: the architecture, the deploy pipeline, and the scars that shaped both.
One repo, fourteen front doors
The layout is deliberately boring. There's a shared core — classes, helpers, the admin panel, the API — and a sites/ directory where each site keeps only what makes it itself:
jekcms/
├── classes/ # shared core — identical for every site
├── includes/
├── themes/ # 13 finished themes + a starter skeleton
└── sites/
├── site-a/
│ ├── config/environment.php # DB credentials, keys — never in git
│ ├── theme/ # site-specific look
│ └── uploads/
├── site-b/
└── ...
A bug fixed in the core is fixed fourteen times at once. That's the whole pitch, and after years of running my own fleet on it, it holds. The caveat is symmetrical: a bug introduced in the core also ships fourteen times at once. Most of the pipeline described below exists to manage that second sentence.
Every site gets its own database
There is no multi-tenant schema, no site_id column threaded through every query. Each site talks to its own isolated MySQL database with its own credentials. The reasons are practical, not ideological: a compromised site can't read its neighbors' data, a schema migration can be rehearsed on a small site before it touches the 2,300-post one, and a restore only ever involves a single site's dump.
Themes follow the same principle. The core ships 13 finished themes plus a starter skeleton; each site either picks one or carries its own overrides inside its own directory. Nothing about site A's design lives anywhere site B could trip over it.
Deploys: GitHub Actions, rsync, and one angry firewall
Push to main, Actions runs the checks, rsync pushes the result to the server. Textbook. It worked fine right up until Hostinger started banning the runner's IP in the middle of deploys.
The cause was honest-looking traffic. The job opened one fresh SSH authentication per rsync target — fourteen sites, fourteen auths in rapid succession, from a datacenter IP. Hostinger's intrusion detection reads that exactly like a brute-force attempt and drops the connection. Result: half the fleet on new code, half on old. The worst possible state.
The fix wasn't fewer sites or slower deploys. It was SSH ControlMaster:
# ssh_config used by the deploy job
Host deploy
HostName server.example.com
Port 65002
ControlMaster auto
ControlPath /tmp/cm-%r@%h-%p
ControlPersist 10m
The first connection authenticates once; every following rsync rides the same multiplexed socket. From the firewall's point of view there is exactly one login. The bans stopped the day this landed.
What rsync is never allowed to touch
There is no --delete in any deploy command, and there never will be:
rsync -az \
--exclude='config/environment.php' \
--exclude='uploads/' \
--exclude='cache/' \
--exclude='logs/' \
./ deploy:~/public_html/
environment.php holds production credentials and exists only on the server. uploads/ holds years of media that exists nowhere else. One careless --delete against either would cost more than every orphaned file the flag would ever clean up. I accept the orphans and prune them by hand once in a while. It's a good trade.
The deploy is not done when rsync exits
This one took me embarrassingly long to internalize: PHP's OPcache does not care that the files on disk changed. You deploy a fix, curl the page, and the bug is still there — because the server is happily executing last week's compiled bytecode. The disk is new; the runtime is old.
Every deploy therefore ends with an explicit OPcache reset — a token-guarded endpoint on each site that calls opcache_reset() — followed by a page-cache purge. The page cache TTL is 300 seconds, so skipping the purge only costs five minutes of staleness. But stale OPcache stacked on stale page cache is how you end up "fixing" the same bug three times in one evening. Reset both, then hit every site and expect a 200.
If you're curious what exactly is being purged there, I've written up the cache layers in jekcms separately.
14,616 NUL bytes, or why CI now greps for garbage
June 2026. Four sites in the fleet were serving completely empty privacy pages. Not 404s — clean 200 responses with nothing in the body. The legal.php in those themes turned out to consist of exactly 14,616 NUL bytes. A file write had failed somewhere along the way and left a zero-filled husk that PHP was perfectly willing to include and render as nothing.
For a network that runs ads, an empty privacy page is a rejection-grade defect. And it sat there silently, because nothing in the pipeline considered "PHP file full of NUL bytes" abnormal.
The guard went in the same day:
if grep -rlaP '\x00' --include='*.php' .; then
echo "NUL bytes found in PHP files — refusing to deploy"
exit 1
fi
It also flags suspiciously empty stub files. Dumb, cheap, and it has already earned its keep.
When Actions can't reach the server
The pipeline has a fallback: self-deploy. A cron job on the server itself can run git pull plus the same post-deploy steps, so if the runner gets banned, rate-limited, or GitHub is having a day, the fleet can still update from the server side. The same rule applies — the pull is not finished until OPcache is reset. If you're setting up server-side jobs like this, the cron setup docs cover the details.
Fleet-scale leverage cuts both ways
Everything that makes one codebase efficient also amplifies mistakes. Two examples from my own logbook, both from June 2026.
First, a data repair that touched around 2,700 posts across all fourteen sites in one pass — plus 137 wrong meta descriptions cleaned out. On fourteen separate installations that would have been fourteen separate maintenance windows. On one codebase with per-site databases it was one script, rehearsed on one site, then looped over the rest. That's the leverage working for you.
Second, the leverage working against you: I once ran an automated internal-linking tool aggressively across the fleet. Efficient, consistent — and it made every site look like a link catalog at the same time. That uniformity contributed to an AdSense rejection. The fix was the opposite of automation: going through posts by hand and choosing links editorially. Some things should not scale, and knowing which ones is part of running a fleet.
Does it actually scale? The honest numbers
Fourteen sites. Past 5,500 posts and growing, with the largest single site at 2,300+. All of it on shared hosting — LiteSpeed with Cloudflare in front, not a VPS cluster. That works because the CMS assumes cheap hosting and caches accordingly; there's a separate realistic guide to jekcms on shared hosting with the details.
The real answer to "does it scale" is that I don't get to avoid finding out. Every jekcms release hits my own fourteen sites before it reaches anyone else's. When a deploy flow is annoying, I'm the one annoyed fourteen times. That feedback loop — dogfooding, if you like the word — has done more for this architecture than any design document I've ever written.