I made my SaaS self-hostable. My laptop had been lying to me.

SendTidings sends automated monthly reports for the client sites I look after. Until this week it needed three accounts that weren’t mine to give away: Neon for the database, Resend for email, and Trigger.dev for the scheduled jobs. Fine for a hosted product. Useless if you want to run it yourself.

So I set about removing all three as requirements. Not ripping them out, just making them one option among several. Bring your own Postgres, bring your own SMTP, run the scheduler in-process if you’d rather not sign up for anything.

That part went about as well as you’d hope. The part I didn’t expect was how many things were broken that I had no way of knowing about, because my laptop had been quietly covering for me the entire time.

The refactors were the boring bit

Three seams, and each was smaller than it looked once I actually measured it.

The database already worked with any Postgres, it just picked the driver from the connection string. A *.neon.tech URL gets Neon’s HTTP driver; anything else gets postgres-js over a normal connection. Thirty lines.

Email looked like the scary one. Fifteen files constructed their own Resend client. But when I grepped for what they actually called, the answer was .emails.send and nothing else. No contacts, no audiences, no domains. Fifteen files, one method. A single sendEmail() wrapper with two backends replaced the lot, and now setting SMTP_HOST is enough to switch.

The scheduler was the same story. Nine jobs, and I’d assumed they’d be knotted into Trigger.dev’s SDK. They used exactly two things from it: schedules.task and retry.onThrow. The bodies were just plain functions calling my own code. So the bodies moved into src/lib/jobs/, the Trigger tasks became three-line wrappers, and a small cron worker runs the same functions for anyone who doesn’t want Trigger.

The lesson there, if you want one: measure the coupling before you dread it. I’d been carrying around a vague sense that this would be a rewrite. It was a couple of days, and most of that was tests.

Then I actually ran the thing

Here’s where it gets embarrassing.

I wrote a Dockerfile, wrote a compose file, wrote the docs, and felt quite pleased with myself. Then I built the images and started the stack, and it didn’t work. Not in one way. In several.

Terminal output from the running container: the scheduler registering seven cron jobs, and db:push applying the schema

That screenshot is from after the fixing. It is also, deliberately, a real one. The old SendTidings homepage carried a mocked-up terminal showing docker compose up and an smtp: ready line months before any of that existed, which in hindsight is a fairly on-the-nose illustration of this entire post.

The app wouldn’t build at all. Not in Docker. Anywhere, for anyone who wasn’t me. src/lib/billing/stripe.ts constructed its client at module scope with new Stripe(secretKey ?? ""), and Stripe throws on an empty key. Next evaluates route modules during the build to collect page data, so the build died on /api/webhooks/stripe.

Billing isn’t needed to self-host. The example env file has no Stripe key. So nobody following my own documentation could get past npm run build. That had been true for months. I never saw it because I always built with my own .env.local sitting there, quietly supplying a key.

The file’s own comment even said importing it mustn’t crash boot. It had been lying about itself in a comment, which is a new one on me.

A file I’d written was never committed. .gitignore has .env* and negates exactly one thing, .env.local.example. My new .env.docker.example matched the ignore, git add -A skipped it without a word, and the merged PR cheerfully told people to run cp .env.docker.example .env, the first step of the quick start, against a file that wasn’t in the repository. I only caught it because I was reading a later diff and noticed the file wasn’t in it.

A required secret was documented nowhere. BETTER_AUTH_SECRET. Not in either env example, not in the compose file, not in the docs. better-auth refuses to run on its default secret, so every page that touched a session returned 500. Again: not a Docker problem. Every self-hoster, however they installed it.

And two smaller ones, both the same shape: the compose file never published the Postgres port, so the db:push command my docs told people to run had nothing to connect to. And drizzle.config.ts wasn’t copied into the worker image, so even once I’d fixed the env handling, drizzle-kit had no config to read.

Five things. Every one of them fatal to a first-time install. Every one invisible from my machine.

The actual lesson

I’d been testing the wrong environment the whole time.

Not carelessly. I ran the build, the tests, the linter, all green. But I ran them in the one configuration where everything already worked: my machine, my .env.local, my credentials, my node_modules. The condition I was shipping to was a fresh clone with none of that, and I had never once put myself in it.

The container was the first honest test. Not because containers are magic, but because a container is the only cheap way I know to be a stranger to your own project.

There’s a nice counterexample from the same week. One bug I did catch by reading: the resendMessageId column is nullable and unique, so when an SMTP server returns no Message-ID, writing an empty string would sail through the first send and then blow up on the second, when two rows collide. Reading caught that one because it was a logic problem, sitting right there in the schema.

Reading finds logic bugs. Running finds environment bugs. I’d been doing plenty of the first and almost none of the second, and the split in what each caught is almost comically clean.

What I’d take away

If you maintain something other people install, the question worth asking isn’t “does it work?” It’s “does it work for someone who has none of what I have?”

Delete your env file and build. Clone into a clean directory and follow your own README like a stranger, doing exactly what it says and nothing you know to do from memory. Better still, build the container, because it forces the issue: nothing carries over unless you deliberately put it there.

I’d assumed shipping self-hosting meant writing docs. It turned out to mean discovering that my documentation had been describing a piece of software nobody but me could actually install.

Everything’s fixed now, and the whole thing runs with docker compose up and no third-party account at all. It’s still under testing and development rather than finished, but it goes open source in the near future. Stay tuned, and if you’d like a nudge when it happens, the self-hosting page is where that lands.

It only took being a stranger to my own project for an afternoon to find out it never had been installable.