← Blog
Post
One Engine, Two Faces

One Engine, Two Faces

10 min read by Charlie Forêt
Podcast Episode

One engine, two faces

I needed a second website, and I already had the code for the first one sitting right there. The obvious move was to copy the folder.

Copy the folder, point a new domain at it, change the colors, done by lunch. Every author-dev who's ever run two sites has felt the pull of that shortcut. I've maintained forked codebases before. I know how they end. Six months later you fix a bug in one copy and forget the other, a feature you built for Site A never makes it to Site B, and the two slowly drift into different apps that hate each other. Copying the folder is a loan you repay every week for the life of the project.

So I did the harder thing. I made one app pretend to be two.

What "multi-tenant" actually means here

integrationera.com and charlieforet.com are different in every way that a reader can see. Different domain, different name, different mood. One is terminal-blue, a single LitRPG world you can climb all the way down into. The other is warm and editorial, the front door to everything else I write, space opera and fantasy and thrillers, the whole spread. They don't look like cousins.

Under the paint, they're the same running program. One codebase, one database, one server. The industry word for this is multi-tenant: many "tenants" living in one building, each behind their own locked door, sharing the plumbing and the foundation. Gmail is multi-tenant. Your inbox and mine run on the same code; you just can't see mine.

The rule I set myself was total separation. Each site keeps its own reader list, its own members, its own everything. A reader who's all-in on the LitRPG world signs up there and belongs there. A reader who wants the full range across genres lives on the other site, on a separate list that never sees the first. Same building, different locked doors, and those doors turned out to matter more than anything else I built. Making "the same code runs both" never slip into "one site can read the other's people" was the entire hard part.

The doorman at the door

Every request to the server now starts with a small piece of code that does one job: it reads the domain you typed and decides which site you're on.

In Phoenix that piece is called a plug. Mine looks at conn.host, the hostname on the incoming request, finds the matching organization in the database, and staples that org onto the request before anything else runs. integrationera.com resolves to one org. charlieforet.com resolves to another. From that point on, every query the page makes is supposed to filter by that org and show only its content.

The setup around it is refreshingly dull. Both domains point their DNS at the same app. Fly, my host, serves a TLS certificate for each domain with one command. No second deploy, no second database, no second bill. Adding a third site someday is a row in a table and a cert, not a new server.

That's the clean version. The clean version took an afternoon. Then came the part that actually mattered.

The scary part: money and content

Here's the confession that makes this post worth writing. My database was only half ready for this.

When I first built the app, I was smart enough to put an org_id column on most tables, a little tag saying which site each row belongs to. Then I was honest enough to admit I had one site and left the runtime single-tenant, meaning the code carried the tag around but never actually checked it. For a month that was fine. One org. Nothing to leak into.

The moment a second org went live, every query that ignored that tag became a door with no lock. And when I audited them, the tables missing the tag entirely were the two I'd least want to get wrong: characters and purchases. Reader-created content and actual money. If the scoping was sloppy, a reader on one site could surface a character or a payment that belonged to the other. That's the kind of bug you don't get to apologize for.

So the real work wasn't the doorman. It was walking every read path in the app, blog, stories, store, newsletter, community, comments, and forcing each one to ask "which org?" before it returned a single row. Then backfilling the tag onto the tables that never had it, content and money first.

The bug that scared me most was quieter than a leak. I'd wired writes to stamp new rows with a default_org_id, a leftover from the single-site days. So a post I created while working on the Charlie Forêt site was getting saved as belonging to Integration Era. No error. No red text. Just my content silently filed under the wrong site, which I only caught because I went looking. I ripped out default_org_id and made every write stamp the org from the request instead. Then I wrote an isolation test whose entire purpose is to fail loudly if Site A can ever see Site B's rows again. That test is the most valuable 40 lines in the project.

The core cutover happened in roughly one very long day. It went live on real traffic the next. I spent the two evenings after that hunting leaks I'd missed, which brings me to the embarrassing half.

Making them look nothing alike

Data isolation is the half that can hurt someone. Brand isolation is the half that just looks bad, and there was plenty of it.

The visual side I'd planned for. Every color in the app is a named token, a variable, instead of a hardcoded value. So a new site restyles itself by overriding a set of tokens, no template surgery. Charlie Forêt got its own look, a warm editorial system I call Meridian, sitting on the exact same HTML that renders the serial's terminal chrome. That part worked close to first try, and I was smug about it for a full ten minutes.

Then the leaks showed up. Not data leaks. Word leaks. The serial's whole interface speaks in a System voice, stat screens and terminal language baked in over a year of building for one brand. On the author site, that voice bled through everywhere. "Integration Era" turned up in an email footer. Terminal vocabulary surfaced on a novelist's landing page. My theme accents were painting the wrong elements because I'd targeted the page root instead of each component. Discussion categories I thought were per-site were quietly shared across both. I spent those evenings playing whack-a-mole with my own branding, and honestly, that hunt is still not completely over. Every so often I find one more place the flagship's personality soaked through.

What the reader gets, what I get

The reader gets two real websites, each one completely itself. Its own front door, its own readers, its own community. Nothing from the other site bleeds in. Land on the world that's yours and it behaves as though it's the only one running.

What I get is the thing I built the whole platform for. One codebase. When I ship a feature, both sites get it the same day, because there's only ever one of it to build. A third pen name, if I ever want one, is a configuration, not a rewrite.

The isolation test still runs on every change. It's watching right now. Good.


Follow the build: Workshop posts land weekly, and the next one is about the tool I was most afraid to replace, the email list. If you'd rather just read, the free story lives one click away on either site, no password.

Join Today & get free audiobook story versions

Join today and get ePub and Audio versions of two short stories.

We'll email it to you. Unsubscribe any time.