Open-Sourcing Your SaaS: The Failures Nobody Warns You About

A shop window painted with the words: if you never know failure, you will never know successPhoto: the blowup
On this page

At four in the morning, hours before tagging a release, I ran one last check on the public repository. Not because anything looked wrong. Because the release notes made a list of claims, and I wanted every claim to be true before publishing them.

The main branch had been broken for two commits. Not failing subtly. The frontend would not build at all.

Nobody had noticed, because nobody had looked. That is what running an open-source repository next to a commercial product does to you, and it is the reason this article exists.

If you are thinking about open-sourcing your SaaS, or you already did and it feels like more work than you expected, this is the list of things that will bite you. Almost none of them are about licences.

If you never know failure, you will never know success.

Sugar Ray Leonard, on a shop window I walked past at exactly the right time

Everything described here is in the open. The release that fixed all of it is v0.3.0, whose notes were the reason I went looking in the first place.

GitHub - PayGlue/PayGlue-OS: PayGlue connects Ghost CMS to any payment provider.
PayGlue connects Ghost CMS to any payment provider. - PayGlue/PayGlue-OS
GitHubPayGlue

First, the words

This article uses a handful of terms that everybody in software says twenty times a day and almost nobody defines. If you already know them, skip to the next section. If you are a founder, a writer, or a publisher weighing up whether to run something yourself, five minutes here will make the rest of the article make sense.

Git is a system that records the history of a set of files. Not “the current version”, the whole story of how it got there. Every change, who made it, when, and why.

A repository, or repo, is one project’s worth of those files plus their entire history. Yours might be a website, an app, or a small service that nobody sees but everything depends on.

GitHub is a website that hosts repositories and adds the social layer on top: browsing the code, discussing changes, running automated checks, letting strangers suggest improvements. Git is the machinery; GitHub is the town square built around it. GitLab and Codeberg do the same job.

A commit is one saved change, with a message explaining it. Think of it as a labelled snapshot: “fixed the login redirect”, “added Dutch translations”. Every commit gets a unique fingerprint, a long string of letters and numbers called a SHA, that identifies it forever. That fingerprint matters later in this article, so hold on to it.

A branch is a parallel line of work. You copy the current state, make your changes there without disturbing anything, and merge them back when they are ready. The main line is usually called main. “Main is broken” means the version everybody else starts from does not work.

A pull request, or PR, is the proposal: “here is my branch, here is what it changes, please review it and merge it into main”. It is where discussion happens, where automated checks report, and where a reviewer says yes or no. On GitLab it is called a merge request; same thing.

A fork is your own complete copy of somebody else’s repository, so you can change it freely. It is how a stranger contributes to a project they do not have permission to write to, and it is how anybody can carry on with a project whose maintainers have wandered off.

CI, short for continuous integration, is a robot that runs your tests and builds your software automatically every time somebody proposes a change. When people say “CI is red”, they mean the robot found something broken. When they say “green”, it passed.

A release, or tag, marks one specific commit as a version worth naming, like v0.3.0, so people can say which one they are running.

Two more that only come up in the uncomfortable part near the end.

A force push overwrites the recorded history with a different one. It is how you rewrite a commit message you should never have written. What it is not, as I will get to, is a delete button.

Garbage collection is the housekeeping that eventually discards data nothing points at any more. Whether it runs, and when, is the difference between “this is hidden” and “this is gone”.

The licence is the easy part

Every “should we open source” discussion starts in the same place: which licence. MIT or AGPL. Open core or source-available. The Business Source License, the Fair Source thing, the Elastic thing.

Pick one, write it down, move on. Genuinely. That decision takes an afternoon and a conversation with someone who knows licensing. It is the part everybody has an opinion about and the part that costs you the least.

What costs you is everything after: your code was written on the assumption that exactly one company would ever run it. That assumption is baked into hundreds of small places you have never had a reason to look at. Publishing the source does not remove the assumption. It just means somebody else now runs into it.

Here is what that looks like in practice.

1. Values that belong to you, not to the software

Start with the obvious category and take it further than feels necessary.

Everybody knows not to commit API keys. Fine. But your code is full of values that are not secrets and are still yours: your API hostname, your dashboard URL, your support address, your analytics project key, the list of hosts your backend accepts, the email address your operational alerts go to.

None of those are secret. All of them are wrong for anybody else.

I had my API hostname written into the source in eleven places. The consequence was not cosmetic, and it is easiest to see from your side rather than mine. Say you had installed this. You open your own dashboard, copy the embed snippet it offers you, and paste it into your site. What you just pasted reads:

<script src="https://api.payglue.io/paywall.js"></script>

That is my address, on your site, in front of your readers. Every visitor who loads that page now makes a request to a company they have never heard of, on infrastructure you do not control and cannot see. Nothing was being done with those requests. That is not the point. It was your traffic, going somewhere you did not choose, and nothing in the interface would have told you.

The analytics module was worse in kind if not in volume. It carried my project key as a literal, which means every build anybody made anywhere reported product analytics into my account. Not maliciously. Just because nobody had asked the question “whose key is this, and what happens when someone else compiles this file”.

That rule sounds obvious written down. It is not what code does by default, because a default is always some value, and the easiest value to reach for is the one that works on your own machine.

2. Things your users cannot configure, even when they want to

This is the category that surprised me most, and it is the one worth stealing.

Environment variables solve the configuration problem for anything running on a server. They do nothing for artefacts that leave the server. The product generates embed snippets: small script tags that a publisher copies out of their dashboard and pastes into their website. Between the copy and the paste, there is no configuration step. No variable. No file. Just a person and a clipboard.

So how does the script know which backend to talk to?

var src = (document.currentScript && document.currentScript.src) || '';
if (!src) {
  var all = document.getElementsByTagName('script');
  for (var i = all.length - 1; i >= 0; i--) {
    if (all[i].src && all[i].src.indexOf('/paywall.js') > -1) { src = all[i].src; break; }
  }
}
var API = src ? new URL(src, location.href).origin : '';

The browser fetched the script from somewhere. That somewhere is, by definition, an address the reader’s browser can reach, and it is the operator’s own install. Nobody configures anything, and it stays right behind a reverse proxy, on a subdomain, or with a CDN in front of it.

The fallback loop matters: document.currentScript is null inside module scripts and in some deferred-execution paths, so the loop finds the tag by filename instead.

The general lesson: find everything your product emits into the outside world. Snippets, webhook URLs you tell people to paste into a payment provider, callback addresses, links in transactional emails, QR codes, anything. Each of those crosses a boundary where your configuration cannot follow. Each of them needs to either derive its own context or be explicitly configured, and the deriving version is almost always better because nobody has to know it exists.

3. Defaults that quietly move your data somewhere else

I had a setting called INTERNAL_ADMIN_EMAIL. It is where the backend sends operational notices: a subscription whose status the system cannot classify, a new support request, an account deletion. Useful stuff. It defaulted to my team address.

Read that again as the person running the install. Those notices carry your customers’ email addresses, because that is what they are about: a subscription that needs looking at, a support request somebody sent you, an account somebody deleted. If you had never touched that setting, every one of those would have arrived in my inbox. Your customers’ addresses, sent to a company you have no relationship with, by software you were running on your own server.

Nobody wrote that on purpose. It is what happens when a value that was correct while exactly one company ran the code survives into a world where anybody can.

Generalise it like this. For every default in your codebase, ask what happens if it is wrong. Most defaults fail loudly and harmlessly: a wrong port, a wrong path, a crash on boot. Those are fine. The dangerous defaults are the ones that fail successfully, where the wrong value still produces a working system that is doing the wrong thing quietly. Email recipients, webhook targets, telemetry endpoints, allowed hosts. For those, empty beats plausible.

4. The seam between the public copy and the private one

If you keep a private repository and publish a filtered subset of it, this section is the one that will cost you the most time, and it will cost it in a way that does not look like work. It looks like nothing happening, until something has been broken for a while.

I copy a filtered subset of a private tree into a public repository with a sync script: exclusion lists, a whitelist of which views are allowed through, hard blocks for anything resembling a secret. It works. The problem is not what it copies. The problem is what happens after.

I hit three failure modes, and every one of them was mine.

Excluded but still imported. You exclude a file because it is commercial, or yours alone, or of no use to anybody running the software themselves. Somewhere else, a file that is published imports it at module level. The public build now fails on an unresolvable import, and your own build is fine because your tree has both files. This happened with an analytics module, a pricing helper, and nearly with a support module. Every time, the fix is easy. Every time, finding it took longer than the fix.

Hand-maintained files drift. Some files cannot be copied wholesale because they differ structurally: my settings module references an internal admin console that does not exist publicly. So the public copy is maintained by hand. Which means it is maintained by memory. Which means that when I hardened four settings in the private repo, the public copy kept the old values for weeks. A separate setting was read by published code and never defined at all in the public settings, so anybody running the published version hit an AttributeError the moment they reached that path. Nothing caught it, because nothing over there exercised the path.

Tests are a second copy. The sync covers source directories. Test directories are a separate copy in the public repo. So behaviour changes travelled and the tests asserting that behaviour did not. Two of them kept asserting the old truth and turned the main branch red.

Now, the reframe that made all three tractable, and it took me embarrassingly long to see it.

Testing your product is not the same as testing the thing you publish.

I test the product properly. Unit tests, service tests, CI on every pull request, a staging environment. All of it runs against the tree I develop in, which is the complete one. The tree I publish is a different artefact: fewer files, different settings, a separate test suite, a build I had never once run.

So the honest version is not “nobody tests it”. It is that I tested the source and never installed the product. Those two sentences sound similar and describe completely different levels of confidence. Every failure mode above is a property of the output, and no amount of testing the input will surface them.

The good news in that framing is that checking an output is dramatically cheaper than restructuring an input.

Two static checks are worth adding alongside, both about twenty lines over an AST:

  • every settings key read by published code must exist in the published settings file
  • no published file may import an excluded one

If you are starting from scratch and can avoid this shape entirely, do. The alternative architecture is open core: the public repository is the base, and the commercial parts sit on top as a clearly separated overlay that is simply absent in the public build. Nothing gets cut, things only get added, and every failure mode in this section becomes structurally impossible. It is the right shape. It is also weeks of work to retrofit once commercial logic is woven through your settings, your routes and your navigation, which is exactly why most people end up with a sync script instead. Know which one you are choosing and why.

5. The failures that are writing, not code

Everything above is technical. This part is not, and it is the part I got most wrong.

Comments name people. I had thirty-six comments and docstrings that named me personally. Things like “reused by both admin list views so [name] can scan status at a glance”. Perfectly sensible in a private repository where there is one reader. In a public one it reads as a codebase written for one person, which is exactly the impression you do not want to give somebody deciding whether to depend on your project.

Seeded content carries a voice. My database migration seeded two onboarding emails with my own copy: signed with a personal name, linking to my dashboard, and switched on by default.

So the first thing your new customers would have received, on your product, was a welcome mail signed by somebody who has nothing to do with you, pointing them at a dashboard they cannot log into.

The compounding factor is the one worth internalising. My admin console is deliberately not part of the public repository, which means there was no screen anywhere in which you could have edited or disabled those emails. Short of opening the database and running an UPDATE by hand, you were stuck sending them.

Commit messages are published documents. This is the one that stung. I had a commit whose message described my internal working conventions rather than the change it made. It was accurate, it was useful internally, and it had no business being on a public repository.

What happens when you try to take it back

This part deserves its own section, because it is the single most useful thing I learned and almost nobody seems to know it.

A force push does not delete anything. Rewriting history detaches the commit from every branch, so it disappears from the log, from blame, from the commits list, and from the pull request view. It looks gone. It is not. The object still exists on the server, and GitHub still serves it under its SHA to anybody holding the link. I checked mine after the rewrite: the full original text came back, word for word.

A GitHub commit page showing a yellow warning banner reading: this commit does not belong to any branch on this repository, and may belong to a fork outside of the repository

What a detached commit looks like from the outside. GitHub tells you plainly that it hangs off nothing, and serves the whole thing anyway. That is my repository, after the rewrite, opened with the old link.

That is not a bug, it is how the platform is built. Detached objects stick around so that nothing is ever accidentally destroyed.

There is an official route, and hardly anyone uses it. GitHub documents the whole procedure under Removing sensitive data from a repository. You rewrite the history yourself, then you ask GitHub Support to remove the cached views and dereference the affected pull requests. They do this. It is a normal, documented request, not a favour.

Expect more affected pull requests than you think. This is the part that surprised me most. Once a commit is merged into main, every subsequent pull request touching that branch references it through the shared history. I wrote to support about one pull request. They came back with five, including three unrelated dependency updates that happened to come afterwards.

They then offer a choice: delete those pull requests entirely, or delete only their internal references, which makes the diffs inaccessible while preserving the titles and comment history. Unless a pull request contains nothing worth keeping, take the second option. The diffs are redundant anyway, the same changes are in your branch history.

GitHub does not remove non-sensitive data and will assist only in cases where we determine sensitive data can not be mitigated by rotating affected credentials.

The first line of GitHub’s own reply

Update, 9 August: they removed it. This article went out while the request was still open, so here is how it ended. I wrote it expecting a no, because mine was not a leaked credential and the first line of their own reply says that is the bar. It went through anyway, together with the five pull requests. The lesson is narrower than the one I published: they help with more than credentials, as long as you describe the case plainly instead of dressing it up as a security incident. If something like this is sitting in your history, find out rather than assuming the answer.

And forks are a limit even they cannot fix. If somebody forked your repository after the commit landed and synced it, that copy is theirs.

The rule I wrote down afterwards: anything published to the public repository, commit message, pull request title, pull request body, issue comment, describes the change and what it means for someone running the software. Never how the work happens internally. Ask whether the sentence would be fine on your front page.

Note the asymmetry. Nearly every other mistake in this article is a normal bug: you find it, you fix it, it is over. This one you can only ever partially undo, and only with somebody else’s help.

I got lucky on timing, and that is most of the story

Everything above sounds worse than it turned out, for one reason that has nothing to do with skill: my public repository is a few weeks old. One fork. A handful of stars. Every fix in this article landed before anybody had built anything on top of the broken version.

That gap is worth being explicit about, because it is the difference between a bad afternoon and a genuine mess.

History rewrites only work while you are alone. I rewrote nine commits to remove one message. Content identical, nobody inconvenienced, because the only clone that mattered was mine. With a thousand forks, every one of those is a copy of the history you are trying to change, and not one of them is yours to touch. You can ask a hosting platform to clean up its own storage. You cannot ask a stranger in another country to garbage-collect their laptop.

Bad defaults harden into installed base. An empty INTERNAL_ADMIN_EMAIL is a one-line change for me. For an operator who has been running the previous version for eight months, changing a default silently changes behaviour on their production system, which means it stops being a fix and becomes a breaking change with a migration note. The longer a wrong default has been shipping, the less freely you can correct it.

Release notes reach a fraction of your users. The most important line in the release notes was the first one: re-copy your embed snippets, the old ones point at my address. At my size that reaches essentially everybody, because everybody is a handful of people who will see it. At scale, a note like that reaches the subset who read release notes, and the rest carry the old snippet indefinitely.

Do this audit before the adoption, not after it. Every hour spent on it now is worth several later, and there is a point after which some of it simply stops being possible.

If you already have adoption, you have not missed the window, you have just lost the cheap version. Fix forward, be loud in your release notes about anything that requires action, and treat every default change as a breaking one until you have checked it is not.

The short version

If you are about to do this, here is the compressed list.

  1. Search for your own domain across the entire tree, then search again in test fixtures, seed data, example configuration files and migrations. Migrations are the one everybody forgets, and they are the one that writes data into other people’s databases.
  2. For every default, ask whether a wrong value fails loudly or quietly. Make the quiet ones empty.
  3. Find everything your product emits past your own boundary. Give each one a way to derive its own context.
  4. Build and test the thing you publish, in your own CI, before you publish it. If you do exactly one thing from this article, this is the one.
  5. Anything that will reach your users’ own customers ships switched off.
  6. Read your comments as a stranger. Then read your commit messages as a journalist.
  7. Decide deliberately between a filtered copy and an open core overlay. Both are valid. Sliding into the first by accident is not.
  8. Bookmark GitHub’s page on removing sensitive data now, while you do not need it. The day you do, you will want to know that a force push is not enough and that there is a documented way to ask for help.

The honest cost

Publishing your source costs a good deal more than a licence decision and a git push. You are taking on a second distribution channel for a product that has only ever had one, and every assumption your code makes about who runs it turns into a bug the day somebody else does.

The good news is that nearly all of it is a one-time cost, and the work makes the private product better too. Every value I pulled out of the source and into configuration is a value I can now change without a deploy. The settings that stopped defaulting to my own addresses are settings I now set explicitly, which means they are visible instead of implicit. The self-locating script tag fixed a class of support ticket I used to get from my own hosted customers behind proxies.

The bad news is that the cost does not show up as a task on a board. It shows up as things quietly not working for people who never tell you, because someone who clones your repository, hits a broken build, and closes the tab does not open an issue about it.

One last thing the audit gave me for free

I went in looking for leaked values and came out with a product question I had not thought to ask.

Going through the install as a stranger would, the first real fork in the road arrives before anything technical: am I running this for myself, or for several publications? The software has always answered that question for you, because in the hosted product there was only ever one right answer. A self-hosted install inherits the multi-tenant machinery whether the person wants it or not, and the interface never asks.

For one person with one Ghost site, that is friction they should not have to think about. For somebody running four publications, the machinery is exactly what they want, and it is currently hidden behind assumptions rather than offered.

So the next release gets a proper setup flow: a first-run wizard that asks the question out loud, and an install that stops requiring a third-party account before it will even start. That is not a bug fix. It is a feature I would never have found by reading my own code, because I already knew the answer and the code was written by people who did too.

That is probably the most transferable thing here. Publishing your source exposes your mistakes, and it also puts a stranger in front of your product with none of your context, which forces you to notice every question you have been quietly answering on their behalf.

If you have a public repository, go and check whether its build is green. Mine was not, and I found out at four in the morning by accident.

Photo by the blowup on Unsplash

Frequently asked

Does a force push delete a commit from GitHub?

No. Rewriting history detaches the commit from every branch, so it disappears from the log, from blame and from the commits list. The object still exists on the server and GitHub still serves it under its SHA to anybody holding the link. Removing it for real means asking GitHub Support to drop the cached views and dereference the affected pull requests.

What is the difference between open core and a filtered public copy?

With open core the public repository is the base and the commercial parts sit on top as a separate overlay that is simply absent in the public build. With a filtered copy you maintain a private tree and publish a subset of it. Open core removes a whole class of failures because nothing is ever cut, only added. It is also far more work to retrofit once commercial logic runs through your settings, routes and navigation.

What should I check before publishing my source?

Search the whole tree for your own domain, including test fixtures, seed data, example configuration and database migrations. Make every default empty when a wrong value would fail quietly rather than loudly. Find everything your product emits past your own boundary, such as embed snippets and webhook URLs, and give each one a way to derive its own context. Then build and test the published tree in your own CI before you publish it.