Ulric
Book a call

Eugene, Oregon · one person, whole builds

Insights

One engine, three client sites: the fork per client model

One engine, three client sites: the fork per client model

Three live sites, three brands with nothing visual in common, one engine underneath all of them. Rose Real Estate in Portland, Grace Tarnasky in The Dalles, and MycoMuse in Colorado and Oregon each run their own private copy of UlricCMS, the PHP and MySQL system this site runs on. One engine, three client sites, forked once each and then owned outright. Which means a security fix I write on a Tuesday exists in exactly one copy until I carry it to the others by hand.

Three browser windows side by side showing the homepages of laurenroserealty.com, gracetarnasky.com and mycomusecolorado.com: a rose-red realtor hero, a warm gold Gorge realtor hero, and a green forest psilocybin facilitation hero. Nothing in the three designs matches.
The same engine, captured live on 4 September 2026. The forks share code, never a look.

What does a fork per client actually mean?

It means the client gets a copy of the codebase, not a seat on mine. When a new site starts, the engine is copied out of the studio's cms/ at whatever commit it is sitting on, with the studio's own theme, uploads, storage and content migrations left behind:

rsync -a --exclude config.php --exclude 'uploads/*' --exclude 'storage/*' \
  --exclude preview/ --exclude 'themes/ulric/' --exclude 'migrations/*' \
  ~/Sites/frida/cms/  clients/<client>/sites/<site>/cms/

From there the fork gets its own everything: a private repository whose root is the site directory, its own SSH key and host alias, its own staging subdomain, its own database pools with their own MySQL users, its own theme. The studio install runs two pools, content and CRM; the realtor forks run three, with a separate pool for the assistant's knowledge. Nothing is shared at runtime. There is no tenant column anywhere, because there are no tenants.

The installer gets scrubbed on the way out, and that part is not cosmetic. The engine's copy carried a hardcoded production database fallback, a set of studio content seeds, and an orgSchema() that writes the studio's address, geo coordinates and founder into the homepage JSON-LD. A fork that skips the scrub publishes my business as structured data about a psilocybin practice.

Then one line goes into the new site's notes.md, and it is the most load-bearing line in the whole ritual:

forked-from: f473858 (frida cms/ engine, 2026-07-17)

That is the real one, from the MycoMuse fork, written the day before the site launched. It is the only thing that will later tell me which engine changes that copy never received.

Why not run all three from one installation?

Because the thing I am actually selling is ownership, and a shared installation cannot be handed over. The published case study for the engine puts it plainly: clients own their deployment outright, no plugins, no subscriptions, no rented anything. A client who wants to leave takes a repository, a database and a hosting account, and nothing of mine has to be untangled from it first.

The tradeoff is not a secret, and it is not mine to discover. Microsoft's Azure Architecture Center has a page on tenancy models that describes what it calls automated single-tenant deployments, one dedicated set of infrastructure per customer, and lists the risk in a sentence I would put on the wall:

Also, ongoing maintenance, like applying new configuration or software updates, can be time consuming.

That is from Tenancy models for a multitenant solution, a page last updated in June 2026. Read it next to what the same page says about the shared alternative: you only have to update one set of resources, but it is "often riskier because changes might affect your entire customer base." For a one-person studio serving small businesses, I will take the fork tax over the possibility that a bad deploy on a Wednesday takes down a realtor, an agent in the Gorge and a licensed facilitator at the same moment.

One honest limit, because I got it wrong in my own notes for a while. A separate SSH key per site is revocable identity, not blast radius, as long as two sites sit in the same hosting account: the keys all log in as the same user. Real separation arrived when each production site moved onto the client's own hosting account, which all three of these have now done.

How does a fix actually reach all three?

By hand, in a fixed order, and the grep at the end is the only part of it that counts as proof. The rule in the studio playbook is one sentence long: a fix is not done until every fork has it. The order is: fix the studio's cms/ first because it is the copy every future fork is cut from, then walk each existing fork, using its forked-from line to work out what diff it is owed.

Here is a real one. In late August, working on a client fork, I read this line in core/helpers.php and stopped:

$ok = hash_equals($_SESSION['csrf'] ?? '', $_POST['_csrf'] ?? '');

Both operands can be empty at the same time, and PHP is happy to tell you what that means:

$ php -r 'var_dump(hash_equals("", ""));'
bool(true)

Measured on PHP 8.5.3, the version on this machine. A POST that arrives with no cookie, and therefore no session token, hits an empty session value and an empty posted value, and the check passes. The protection is real for anyone with a session and absent for anyone without one, which is the worst of both, because it looks defended. The version in the reference copy now refuses the empty case and says why:

/* the session token must exist AND match, hash_equals('','') is true,
   so an empty check would wave through any fresh no-cookie POST */
$ok = ($_SESSION['csrf'] ?? '') !== ''
      && hash_equals($_SESSION['csrf'], (string) ($_POST['_csrf'] ?? ''));

Note the direction that fix travelled. It was found in a fork, fixed there the same day, and then carried up into the studio's copy, which is where the next fork will inherit it. The reference copy is not where good ideas are born. It is where they are kept so they are not lost. Carrying one back down to the forks that already exist is a separate act, and that is the act that fails.

Diagram: the studio's UlricCMS install on the left and three fork cards on the right, for Rose Real Estate, Grace Tarnasky and MycoMuse, each naming the client's own domain. Below, the five steps a single fix takes: find it anywhere, fix the reference copy, ask each fork what it is missing, apply and grep for proof, deploy each site on its own.
The forked-from line is what turns "port this everywhere" into a finite list of diffs.

The last step is the one people skip. A sweep is not finished when the edits are made, it is finished when a single command can show every copy in the same state:

grep -n "hash_equals" clients/*/sites/*/cms/core/helpers.php

I ran that while writing this post, on 4 September, and it did not show every copy in the same state. One live fork was still on the old one-line check, days after the guarded version had been sitting in the studio's copy. The port went out the same evening, and the narrower version of the command, the one that names any of the three live forks still missing the guard, now comes back empty:

grep -L "!== '' && hash_equals(" clients/*/sites/{rose,grace,mycomuse}/cms/core/helpers.php

That is the whole argument for ending a sweep with a command rather than a memory. Asked the day before, I would have said the fix was everywhere.

What happens when a fork drifts?

It starts carrying bugs that were fixed months ago somewhere else. One morning in August I ran the first real two-way sync between two of these installations, and the older one was two fixes behind in ways nobody had noticed: its media uploader's dimension probe returned a 404 on every clean media path, so adding a photo said "could not read that file", and saving a post rebuilt the post's metadata from its FAQ alone, which would have silently erased a gallery on the next edit. Both had already been solved in the newer fork.

The pattern that made that sync safe is now the one I reuse. One read-only audit agent reads both trees and produces three lists, portable, data-coupled and do-not-port, with a divergence flag per file. Then merge agents work in parallel over disjoint file sets, so two of them can never touch the same file. The receiving fork wins every conflict, because its own features are the ones in production. Nine files had diverged on both sides, and those were hand-merges only, no agent. Afterwards the whole tree is scanned for cross-brand strings, because engine code is allowed to cross between installations and a client's name, palette or copy never is.

What has the fork model cost me?

Four kinds of quiet failure, and the word "quiet" is the important one. Every item below first presented as "the change had no effect" while the code was perfectly correct.

A migration that wrote into the right table in the wrong database. The forks route tables to pools by name, and the installer leaves empty stub copies of leads, automations and settings in every pool. An older migration resolved its pool by probing, first database that answers a SELECT 1 wins, so on 8 August 2026 it found the stub: it updated content.automations, reported success, and changed nothing, because the application reads crm.automations. Migrations now resolve pools by name, mirroring the routing map in the code. Cross-pool joins are impossible when each pool has its own MySQL user, so this failure mode never raises an error. It just leaves data somewhere nothing reads.

A working directory that moved between two commands. The shell's directory persists across tool calls in my harness, and a blocked call can reset it. On 24 August 2026 a relative rsync cms/ ... ran from the studio root instead of a client site directory and pushed the studio engine onto a client's live docroot. It exited zero. The site kept serving until two class names collided, and cleaning up meant a reviewed dry run and then the removal of 2,997 stray files. Deploy commands now name the source with an absolute path and print pwd in the same command, which is the kind of rule you only write after you have needed it. I have written about the guardrails that catch this class of thing before it reaches a server.

An agent that edited the wrong repository and reported success. Six days later, on 30 August, a delegated task meant to port an engine fix into the studio copy created its git worktree inside a client checkout. Worktree isolation resolves "the repo" from the current working directory, and the shell was still standing in a client site from earlier in the session. The agent edited the fork, reported done, and the port never happened. Delegated edits now name the repository path explicitly and verify git remote -v before touching a file.

An ignore rule that did not survive the layout change. In the studio repository the project is the root and the site lives under cms/, so cms/uploads/* is the correct ignore. In a fork the site directory is the repository root, so that path matches nothing and the live upload folder is simply /uploads/. One git add -A swept 682 files of client media into history, including a 283 MB hero video. GitHub blocks files larger than 100 MiB, so the branch could not be pushed at all, and the repository had reached 1.4 GB. Rewriting the history took it to 19 MB, and the push that had been failing for an hour finished in four seconds.

The general shape: forking the code forks the assumptions too. Every path, every ignore rule, every "first pool that answers", every relative command carries a belief about where it is running, and the fork is a different place. The fix is the easy half of an engine fix. The sweep is the deliverable.

What did launching this way look like for each site?

Fast for the two that started from nothing, and continuous for the one that grew. The fork model itself was decided on 17 July 2026 and proven on MycoMuse that same week: the site went live on its own domain on 18 July with structured data, an age gate and a lead pipeline already in place, and the case study carries the honest span in its title, "online in a week". Grace Tarnasky's build ran on the same shape: brief on a Thursday, staging live by Saturday, production identical and launch-ready on 9 August, then a public launch on 19 August when she was ready. The work was done inside the week; the switch waited for her, not for me. Her build week is written up in its own post.

Rose Real Estate is the other pattern, and I am not going to compress it into a number it does not deserve. It launched and then kept growing: the engine sync on 9 August, a CRM hub on 12 August with lead push, person pages and nightly backups she owns, an MLS feed on the live RESO Web API in production on 28 August, and a map of every active listing at /homes/map built on MapLibre and self-hosted tiles. Each of those is an engine change somewhere, and each one raised the same question at the end: does this belong only here, or does it belong in the reference copy, and therefore eventually in all of them?

A browser window showing the listings map at laurenroserealty.com/homes/map: a map of Oregon covered in circular clusters, each giving a listing count and an average price, with For sale, Pending and All active filters above it and the MLS attribution line in the corner.
A feature that started on one fork. Whether it becomes engine or stays local is a decision, not a default.

That question is the actual work of running an engine across several sites, and I answer it wrong sometimes in both directions. The photo pipeline that failed silently on the feed site is a fix I wanted everywhere immediately. The nine hand-merged files from August are the opposite: too entangled with one brand to ever be portable, and the honest move was to leave them alone. I still do not have a rule that tells the two apart before I start.

Common questions

What is a fork per client CMS?

Every client site gets a full standalone copy of the engine instead of an account on a shared installation: its own private repository, its own deploy key, its own staging subdomain, its own database pools and MySQL users, and its own theme. Nothing is shared at runtime, so no client site can affect another one, and the client can be handed the whole thing.

How does a security fix reach every client site?

By hand, in a fixed order. The fix lands in the studio's reference copy first, because that is the copy every future fork is cut from. Then each fork is visited using the forked-from commit recorded in its notes when it was created, which turns "port this everywhere" into a finite list of diffs. The sweep ends with one grep across every fork so the state is visible rather than assumed.

Why not host every client on one shared installation?

Because a shared installation cannot be handed over, and because one bad deploy would be everyone's outage at the same moment. Microsoft's own tenancy guidance names both sides: per-customer deployments isolate the data but make updates time consuming, while a single shared deployment is cheaper to update and riskier because a change can affect the entire customer base.

What does the client actually own?

A repository, a database and a hosting account, all in their own name, with the engine licensed to them at no cost. There are no plugins and no subscriptions in the stack, so leaving means taking the files, not extracting them from a platform.

What goes wrong most often with per-client forks?

Not the code, the sweep. A migration resolving its database pool by probing instead of by name, a relative deploy path after the shell directory moved, a delegated agent creating a git worktree from the wrong repository, and an ignore rule written for a layout the fork does not have. Each one reported success and changed nothing, or changed the wrong thing.

Related

← All insights