← Playbook

One binary, one SQLite file, a goroutine per chore: why I reach for Go for small services that have to last.

Three Go services I wrote and run, read back from their own source: a childcare compliance backend with its own PDF e-signature, a first-party analytics collector that has taken 180,000 events in five megabytes of memory, and the public copy of that collector. What Go made cheap, where I cut a corner I can now point to, and what I would change.

2026-10-14/11 min read/Levelbrook AI Practice

By Patrick Donahue.

The honest case against

Go is a verbose language with a thin standard library for web work, an error-handling style that doubles the line count of anything that touches a network, and no framework that hands you migrations, mailers, background jobs and an admin screen on day one. I spent seven years on a large multi-tenant B2B product in a framework that does give you all of that, and for a product with a big team and a big schema I would still pick it.

This essay is about a different kind of software: the small service that one person writes, that has to run for years on a cheap box, and that nobody wants to think about once it works. For that kind of service I reach for Go, and the reasons are boring enough that I can show them in code rather than argue them. Below are three services I wrote, read back from their own source tonight, with the parts I would do differently left in.

The shape all three share

The common shape. Measured details for each service are in the sections below.
The common shape. Measured details for each service are in the sections below.

The pattern is one process, one data file, no sidecars. The binary is built with CGO_ENABLED=0, so it has no C toolchain at build time and no shared libraries at run time, which means the runtime image can be distroless/static or a bare Alpine with certificates. The database is SQLite through modernc.org/sqlite, a translation of SQLite into Go, which is the reason the build can stay CGO-free. Background work is a goroutine per job, and every goroutine listens to the same context, which is cancelled when the process gets SIGTERM. A deploy is “build image, swap container”, and a backup is “copy a file”.

ComplianceKit: a real product on one binary

ComplianceKit is compliance software for licensed childcare providers in California, Texas and Florida. A centre has to keep immunisation records for every child, TB tests and background checks for every staff member, fire drills, posted notices and staff-to-child ratios, all against state rules with their own form numbers. The product tracks those documents, chases the parents and staff who owe them, and gives the director an inspector’s walk-through checklist in the state’s own format (LIC-9099 in California, HHSC Form 2936 in Texas, CF-FSP 5316 in Florida).

118commits, April to October 2026
~24,000lines of non-test Go
394Go test functions
25.9 MBstatic linux binary

The binary figure is from a build I ran tonight with the repo’s own flags (-trimpath -ldflags="-s -w"). Most of it is the AWS, Stripe and Twilio SDKs, and I am fine with that: 26 MB in a distroless image that runs as a non-root user is a smaller and quieter thing to operate than most language runtimes before any application code loads.

The rules engine has no I/O. Each compliance rule is a struct with a Check func(ProviderFacts, time.Time) CheckResult field, and the package comment says it plainly: the engine never touches the database. The caller builds a snapshot of the provider (children, staff, documents keyed by subject and kind, a ratio flag, the drill count for the last ninety days), passes in “now”, and gets back satisfied or violated, an upcoming date if something will lapse soon, and a fix hint. Each rule carries its citation, such as CA Title 22 ยง101216, and its severity. There are ten per state today. Because time is a parameter, the tests can ask “what does this centre look like on the first of next month” without mocking a clock, and that is most of why the test count is high.

Models sit at the edges, behind an interface, and never decide anything. Uploaded documents go through OCR, then a model is asked for the expiry date as JSON. Go interfaces make the fallback almost free to write:

type OCR interface {
    Extract(ctx context.Context, r io.Reader, mimeType string) (Result, error)
}

// Chain: fall back on error or when confidence < threshold.
func Chain(primary, fallback OCR, threshold float64) OCR

In main.go that becomes Mistral OCR first, Gemini second, threshold 0.6. The chain buffers the upload once so it can be read twice, and if the fallback also fails it returns the primary’s low-confidence text rather than nothing. The expiry extractor’s answer is run through time.Parse("2006-01-02", ...), and anything that does not parse becomes an empty date with confidence zero. An OCR or model failure is a log line and a document without an extracted date; it never fails the upload. Whether a centre is compliant is decided by the tested rules engine reading dates a person can see and correct. I think that division (models propose, deterministic code disposes) is the most useful thing in the repo for anyone putting language models into a product where a wrong answer has a regulator attached.

The e-signature is in-house. Directors send enrolment and policy PDFs to parents to sign. The browser stamps the signature onto the PDF and hashes it; the server re-reads the bytes through a capped reader (25 MB, via io.LimitReader(r, cap+1) so an oversized upload is detected without being held in memory), checks for a %PDF header, recomputes the SHA-256, and refuses the request if the client’s hash and the server’s differ. It then writes the signed PDF and a JSON audit record (consent text version, typed name, IP, user agent, both hashes) to object storage and inserts a row. The design doc is explicit about what this is not: no qualified signatures, no RFC 3161 timestamp authority, one signer per session. That is enough for the US ESIGN Act and the UETA states it launches in, and the non-goals are written down so nobody mistakes it for more.

Six background jobs, zero schedulers. main starts the document chase, session cleanup, the nightly compliance snapshot, a score snapshot, a trial reminder and a 90-day data purge, each with go worker.RunDaily(rootCtx). Each loop runs once at boot, then on a 24-hour ticker, and returns on ctx.Done(). rootCtx comes from signal.NotifyContext(ctx, SIGINT, SIGTERM), and the HTTP server gets a 20-second Shutdown window. The chase is the one with real rules: reminders at 90, 60 and 30 days before expiry, then weekly forever, deduplicated by a primary key on (document, threshold, channel), and never sent between 21:00 and 08:00 in the recipient’s time zone.

SQLite, tuned for what it is. The repo started on Postgres and moved to a single SQLite file in WAL mode (its ADR-017). db.Open sets journal_mode=WAL, synchronous=NORMAL, foreign_keys=ON, busy_timeout=5000 and temp_store=MEMORY on every connection, and keeps the pool small, because SQLite has one writer and a large pool only manufactures SQLITE_BUSY. There are 87 migration files.

lbtrack: 719 lines that have run since September

lbtrack is the analytics collector for the sites I run. A small tracker posts to a Cloudflare Pages Function on the site’s own domain; the Function adds what only the edge knows (the organisation that owns the visitor’s network, ASN, city, TLS version, Cloudflare’s verified-bot category) and forwards the batch to the Go service on a 2 GB Hetzner box. The full argument for building it is in Own your analytics; here is the Go.

It is two files and one dependency. main.go holds the schema, the classifier and the ingest handler; api.go holds the dashboard queries. The dashboard HTML and the tracker are compiled into the binary with //go:embed web/*, so the deployable is one file. The router is the standard library http.ServeMux. The ingest handler wraps the body in http.MaxBytesReader at 1 MB, decodes the batch, opens one transaction, prepares one 63-column insert, and executes it per event. Schema changes are applied at startup, and so are corrections: when I add a proxy network to the classifier, reclassifyOrgs() re-buckets every historical row from that network on the next boot. Raw rows plus a re-runnable classifier is the whole reason it exists.

5.1 MiBresident, read from docker stats tonight
192 MiBcontainer memory cap
180,818events stored, 4 Sep to 13 Oct
145 MBSQLite file

For scale, the staging copy of a web app built on a heavier framework, on the same box, was using 133 MiB at the same moment, and the box as a whole had 168 MB available out of 1.9 GB. That number is the argument. On a box this size every service pays rent in resident memory, and a collector that costs five megabytes is one I never have to think about when placing the next thing. The extreme bootstrapping essay covers how the rest of the box is packed.

shoestring is the public, de-branded copy: same collector, with my hostnames and keys replaced by environment variables, plus tests for the org classifier, the bot flags, site aliasing and referrer parsing. It also ships boxwatch, which reads the reverse proxy’s request log over SSH and reports p50, p95 and p99 latency, 5xx rates and container memory against its cap. boxwatch is Python, because it is a script that runs on my laptop and exits; the long-running part is the part in Go. Its story is in You do not need an APM subscription.

What Go actually bought

Reading the three back to back, the list is short and specific.

What each property saves, as it shows up in these repos.
What each property saves, as it shows up in these repos.

A static binary means the production image has nothing in it to patch except my code and the CA bundle. context.Context threads one cancellation through every HTTP request, every outbound model call (the OCR clients build requests with http.NewRequestWithContext), and every background loop, so “shut down cleanly” is a property of the whole program rather than something each job implements. database/sql with SQLite turns operations into file handling. And one-method interfaces like OCR mean the model vendor is a constructor argument: when one provider changes its API or its price, the change is one new type and one line in main.

The memory story falls out of these. There is no interpreter, no per-request allocation of a large framework object graph, and no worker processes to multiply it. The collector sits at about five megabytes because there is almost nothing in it that is not the job.

Where I cut corners

These are in the code today. None has bitten yet; all of them would, at a different size.

The rate limiter never forgets. ComplianceKit’s TokenBucket keeps a map[string]*bucket keyed by scope and client IP, behind a mutex, and nothing ever deletes from it. Memory grows with the number of distinct IPs until the process restarts. It also trusts the first X-Forwarded-For value, which is correct only behind a proxy that overwrites that header. The fix is a periodic sweep of buckets idle for longer than the time to refill, and reading the client IP from the one header the proxy is known to set.

Finalize can orphan a PDF. If writing the audit JSON fails after the signed PDF was stored, the function returns an error, and a comment says it attempts to clean up the PDF. It does not. A failure to mark the session completed is also discarded. Neither loses a signature, since the hashes live in three places, but the comment and the code disagree, and that is the sort of drift I would rather catch in review than in an audit. The fix is a deferred delete keyed on success, plus a nightly reconciliation of storage keys against rows.

“Daily” means 24 hours after the last deploy. Every RunDaily loop runs at boot and then on a 24-hour ticker, so the chase runs at whatever time I last shipped. The quiet-hours rule protects recipients, but the snapshot and purge jobs drift. I would compute the next wall-clock run time and use time.NewTimer instead.

Names outlived the migration. The signature store is still called PgStore and its comments still say Postgres, months after the move to SQLite. The Dockerfile builds with a Go 1.22 image while go.mod asks for 1.25, and the official image sets GOTOOLCHAIN=local, so that file will not build as written until the base image is bumped. Both are cheap to fix, and the second is the kind a fresh deploy finds before any reader does.

lbtrack is written like a script, because it started as one. The database handle is a package global. parseUA calls regexp.MustCompile inside its loop, compiling up to six patterns for every event, when they belong at package level beside the other classifiers. A failed insert is logged and the handler still answers {"ok":true}, which is right for a fire-and-forget edge Function and wrong if you ever need to know about data loss. There is no graceful shutdown; log.Fatal(srv.ListenAndServe()) is the whole lifecycle, and WAL is the only reason that has been fine. The public copy has tests and the private one does not. If I were starting it again I would start from the public copy’s shape.

When I would not use it

If the product is mostly forms over a large relational schema with a team of six, the framework that generates the forms wins, and Go’s lack of one is a real cost paid every week. If the service needs a second writer process on the same data, SQLite stops being the right file and Postgres earns its container. And if the work is exploratory data handling, Python gets to an answer sooner, which is why boxwatch is Python.

For the service that has to sit on a small box for years, take modest traffic, call a model or two, run its own chores and shut down cleanly when the box restarts, I have not found anything that asks less of me once it is running. ComplianceKit’s whole backend is one 26 MB file, and the analytics for every site I run fit in five megabytes of memory and one database file I can copy to my laptop.

Sources and things reacted to
  1. ComplianceKit (GitHub): Go backend, React frontend, self-hosted PDF e-signature 118 commits, April to October 2026; every code detail below is from this repo
  2. shoestring (GitHub, MIT): the analytics collector and boxwatch the public, de-branded copy of the collector described in the second section
  3. modernc.org/sqlite the CGO-free SQLite driver both services use
  4. Go: signal.NotifyContext the one call that ties shutdown to every background loop
Keep reading

This is what we do all day.

Support automation, AI phone agents, n8n back-office work, and the engineering loop itself — always behind a gate you control.