·5 min read#Open Source#Tools#Engineering
Why I rewrote two of my Go tools
Seven months after arguing for Go, I rewrote Lightr and SimpliCD. The language was the least interesting part: it was about boundaries, and about treating a bug report as the spec.
In February I wrote The case for building your own tools, and in it I said Go's distribution story was unbeatable. Since then I've rewritten two of the tools from that post, Lightr and SimpliCD, and neither of them is written in Go any more.
That sounds like I changed my mind about languages. I didn't, really. Both rewrites came down to the same two lessons, and the language was the least interesting part of either. This post is about those lessons, with enough of the detail that they don't read like slogans.
Own what's actually yours
The Go version of Lightr was a single binary that did everything: SMTP in and out, the queue, the API, and serving the mailboxes over IMAP itself. That was the whole idea. One thing to deploy, one thing to understand.
The trouble is that IMAP is enormous. It isn't just "list the messages". It's UIDs that have to stay stable and only ever go up, flags that several clients change at once, partial fetches of huge messages, IDLE connections that sit open waiting for new mail, and decades of client quirks that every serious server has had to learn about one bug report at a time. Doing it well is years of work.
Mine had a real flaw, and it was a structural one. Its UIDs were positional, so they shifted every time a message was deleted. A mail client caches what it's seen by UID. Delete one message and every UID after it changes, and every client's cache quietly goes stale: the wrong message opens, or a flag lands on the wrong email. Fixing that properly meant redesigning storage, and at that point I was writing an IMAP server, not a mail engine.
So the rewrite moved the line. Lightr now owns the parts that are genuinely its own:
- Getting mail in and out: receiving on port 25, authenticated submission on 587, and the outbound queue with retries, bounces and a suppression list.
- Policy: checking SPF, DKIM and DMARC on the way in, scoring spam, and signing with DKIM on the way out.
- Identity: organisations, domains, accounts and aliases, with pluggable authentication and API keys scoped to a tenant.
- The operator surface: a REST API, a CLI and signed webhooks, all over the same objects.
Dovecot, which has done IMAP properly for a very long time, owns the mailboxes. Lightr hands it local mail over LMTP, stored as Maildir, one file per message. And Lightr drives Dovecot itself. It writes Dovecot's config, sets up the master user and authentication, checks the result with Dovecot's own parser, and reloads it. Nobody running Lightr ever has to open dovecot.conf.
The split made Lightr sturdier in a way I hadn't planned for. If Dovecot goes down, the API, the queue and SMTP receive keep working, so mail is still accepted while the mailboxes are away. One process that does everything also fails at everything at once.
One binary felt simpler. Two programs with a clean line between them turned out to be simpler to trust.
It's also what this site runs on. The mail client in my admin is built on Lightr's mailbox API, and signing into the site signs me into my mailbox through its HTTP authentication hook. I use the thing every day, which is the fastest way I know to find out what's wrong with it.
The bug report is the spec
SimpliCD's Go version worked, but too much of it was almost finished. There was a dashboard you couldn't finish a task in, a GitHub integration that still needed hand-edited YAML, and a deploy pipeline with a race in it that could report success while the old release was still the one being served.
That last one is the worst thing a deploy tool can do. A deploy that fails loudly costs you a few minutes. A deploy that says it succeeded while serving the old code costs you an afternoon of wondering why your fix didn't work, and it teaches you to stop trusting the tool.
So I didn't start the rewrite from the old code. I started from the bug report, turned every finding in it into a regression test, and wrote the new deployer to pass them. A deploy is now five steps, and each one exists because of something that went wrong:
- Lock. One lock per project, so two pushes in quick succession can't race each other.
- Fetch. A shallow, single-branch clone, or a snapshot of a local folder.
- Build. Detect the framework, run the build, and refuse to promote a build that produced no output, because an empty build that "succeeds" is how you deploy nothing.
- Swap. Stop the old process and confirm it's actually gone, wait for the port to free up, and only then switch the version pointer.
- Verify. Check that the new process is alive, that it's running from the new release's directory and not the old one, and that its health check answers. If any of that fails, it rolls back and restarts the previous version on its own.
The fourth and fifth steps are the old race, fixed twice over. The old version assumed the previous process had stopped. The new one checks, and then checks again from the other side.
The rewrite also made SimpliCD easier to start with. It detects the framework (Next.js, FastAPI, Django, Go and others) and works out the build command, start command, port and health check from it, so a working config can be two lines: a name and a repository. And the config is strict. An unknown key is an error, not something silently ignored, because a typo in a deploy config should stop you, not surprise you later.
I also have to take back something I wrote in February, that SimpliCD "deliberately has no UI". It has a dashboard now, for deploying, rolling back, reading logs and editing environment variables. It's server-rendered, with no build step and no CDN, because a deploy tool often runs somewhere without outbound internet, and a dashboard that needs a CDN to load is no use on a locked-down server. The principled choice was really just the one I hadn't needed to revisit yet.
What I kept from Go
What I valued in Go was never the language. It was the install: download one thing, run it, no runtime to wrangle.
That part I kept, just differently. SimpliCD's .deb carries its own Python, so it installs on distributions whose system Python is too old, and it needs nothing beyond libc and git. Lightr's Debian package pulls in Dovecot and runs its own setup on install, and it can also go in with pipx. It's the same promise I made in February: one thing to install, and it works.
Leaving a way back
Both rewrites also take migration seriously, because a rewrite that strands the people using it isn't finished.
lightr migrate adopts an existing Go-era database in place, so accounts, domains and aliases carry over. simplicd migrate translates an old config into the new format, and it names anything it couldn't carry across instead of dropping it quietly. That second part matters more than it sounds. A migration that silently loses one setting leaves you with a deploy that behaves slightly differently for no visible reason. And the Go versions are still there, on their own branches, for anyone who needs them.
Where that leaves the February post
I don't think it was wrong about building your own tools. It was incomplete. Building a tool teaches you the protocol. Rebuilding it teaches you where the boundaries should have been in the first place, and a bug report is the most honest specification you'll ever be handed.
Thanks for reading
Read next
Honours at the University of Tokyo's GCI World