How we built Soaring Spirit
Soaring Spirit is my first company. It’s a Swiss GmbH, it runs speedflying and paragliding trips and coaching, and the people in it are pilots rather than programmers — Greg cooks and does the osteopathy, Jamie has more speedwing hours than almost anyone alive, Oli drives the bus and keeps the books, Nika gets us into the parts of Georgia nobody else can reach. So when I say “we built”, I mean the company is a we. The software is mine, and this post is about the software.
First commit was 2025-03-13. Sixteen months and 706 commits later it books trips, takes deposits and balances through Stripe, issues invoices, runs a coaching-availability calendar, hosts a flying syllabus, keeps a database of speedflying incidents scraped from the Swiss and French fatality reports, and logs in every user without ever having stored a password.
All of it is one Go binary, cross-compiled to arm64 and copied onto a Raspberry Pi in my house.
I want to write about the constraints rather than the features, because the constraints are the interesting part. Nearly every decision here was some version of “what is the smallest thing that can possibly work”, and a few of them were wrong in ways that took months to surface.
One binary, one machine
The deploy script is worth quoting more or less in full, because it is the whole operations story:
GOOS=linux GOARCH=arm64 go build -ldflags "..." -o ss_linux_arm64 .
scp ss_linux_arm64 "$HOST:~/ss.new"
ssh "$HOST" "mv ~/ss.new ~/ss && sudo systemctl restart ss"
That’s it. Build, copy, restart. The Pi sits behind a Cloudflare Tunnel, so there is no public IP, no inbound port forwarding, no load balancer and no bill. Postgres runs on the same machine. pgbackrest ships backups to S3 on a schedule.
The frontend is deployed by rsync, and then the Cloudflare cache is purged. There is no build step for it at all — I’ll come back to that.
The obvious objection is that this is one machine and one power cut. That’s true, and it’s a real risk I’ve accepted rather than solved. The mitigation is entirely on the data side: the backups are off-site and verified, and the binary can be rebuilt and redeployed onto anything with an ARM chip in about ninety seconds. What I’d lose in a hardware failure is availability, not the business. For a company that sells six trips a year to people who mostly book after talking to us in person, that’s the right trade. It would be a bad trade for something with real-time revenue.
The less obvious cost is that one process serving both the API and the site means a deploy takes the whole site down for the second it takes systemd to restart. I’ve never minded this and I doubt anyone has noticed, but it’s the kind of thing that’s fine until it isn’t.
Ten dependencies
The go.mod has ten direct requirements: a Postgres driver, Stripe, WebAuthn, an SMTP pool, three image libraries, and three golang.org/x packages. No web framework, no ORM, no dependency injection container, no code generation.
The router is 154 lines. Here is essentially all of it:
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
for _, route := range r.routes {
params, ok := matchPath(path, route.Segments)
if ok && route.Method == req.Method {
// ...inject params into the context, wrap in middleware, serve
}
}
r.notFoundHandler.ServeHTTP(w, req)
}
A linear scan over about 110 routes, splitting the path on / and comparing segments. It is not clever and it does not need to be — this is a Raspberry Pi answering maybe a few thousand requests a day, and the linear scan is nowhere near the top of any profile I’ve taken. I wrote it before Go 1.22 gave the standard library pattern matching, and I’ve left it because it works and because the middleware ordering is explicit in a way I like.
I’m not going to pretend this is universally the right call. I wrote a router, a session store, an image pipeline and a mail queue that all exist as libraries, and each of them cost me a weekend I could have spent on the actual product. What I got back is that when something breaks at eleven at night before a trip, the stack trace goes through code I wrote, and I can read all of it. That has paid for itself several times.
The honest counterweight is the test suite: about 11,000 lines of Go tests against 19,000 lines of everything else, across 31 integration test files that run against a real Postgres in Docker Compose. Writing your own primitives is only defensible if you also write the tests that a library would have shipped with. I didn’t understand that at the start and the early code shows it.
No frontend build step
The frontend is about 20,000 lines of hand-written JavaScript. Lit is vendored as a single file. There is no bundler, no transpiler, no npm run build, no node_modules in the deploy. Files are served from disk, so an edit is live on reload.
Routes are plain objects with a dynamic import:
{
path: "trip/:id",
component: "ss-trip-detail",
action: async () => await import("./components/app/trip/trip-detail.js")
}
The browser does the code splitting, because ES modules already are code splitting. Every component is a LitElement with its styles in a tagged template literal next to its markup.
What this buys is that the edit-to-live loop is a file save, and that there is no build to break. What it costs is that node --check is the entire compile step — nothing catches a typo in a property name until it renders wrong. Over sixteen months, that has cost me less than I expected. Shadow DOM turns out to do most of what a build step’s CSS scoping would.
It has one wart I still haven’t fixed properly. The server pre-renders the SPA shell’s <head>, so it keeps its own table of route titles and descriptions in app/prerender.go, mirroring the one in site/index.js. Two hand-kept copies of the same data. It’s documented in three places and I’ve still got it wrong once.
Money is where the discipline goes
Everything above is “how little can I get away with”. Bookings are where that stops.
A booking moves through ten states — DRAFT, IN_REVIEW, AWAITING_DEPOSIT, DEPOSIT_PAID, DEPOSIT_TO_REFUND, DEPOSIT_REFUNDED, AWAITING_BALANCE, FINALISED, VOID, REJECTED — and the legal transitions are an explicit switch, not a convention:
switch current {
case BookingStateInReview:
if next == BookingStateAwaitingDeposit || next == BookingStateRejected || next == BookingStateVoid {
return nil
}
case BookingStateDepositPaid:
if next == BookingStateDepositToRefund || next == BookingStateAwaitingBalance {
return nil
}
// ...
}
return errInvalidTransition
Every transition goes through one function, which opens a transaction, takes SELECT state FROM app.bookings WHERE id = $1 FOR UPDATE, validates, and writes with the old state in the WHERE clause. The row lock is there because the check-then-write without it is a textbook TOCTOU: two concurrent callers both read AWAITING_DEPOSIT, both decide the transition is legal, both proceed.
There’s one flag on it that took me a while to get right. A transition to the state you’re already in is treated as a no-op rather than an error, because Stripe redelivers webhooks and a retried handler re-observing its own applied state is normal. But a caller that’s about to issue an actual refund on the strength of that transition cannot accept a no-op — two callers racing there means refunding twice. So RequireStateChange turns the no-op back into a conflict for exactly those callers:
// Set this for callers about to perform an external side effect (e.g. a
// Stripe refund) on the strength of this transition, so a second,
// concurrent caller that raced past the same check doesn't repeat the
// side effect.
The retry loop
The bug I’m most glad to have found is not in that machine, it’s in what surrounds it.
Stripe redelivers any webhook that doesn’t return 2xx, backing off over several days. That’s correct for a transient failure — database down, network blip — where a later attempt might work. It is actively harmful for a deterministic rejection.
During the host merge in July, a past invoice.paid event got resent for a booking that had already reached FINALISED. The handler read it as a deposit payment and attempted FINALISED → DEPOSIT_PAID. The state machine rejected it, correctly. The handler returned 500. Stripe retried. The state machine rejected it again, identically, because the state machine is deterministic — that’s the entire point of it.
So it retried for days.
The fix is a distinction rather than a patch: an invalid-transition error is now a sentinel that the webhook handler acknowledges with a 2xx, because “received, nothing to do” is the honest answer when the booking is already in its terminal state. Only genuinely transient errors get a 5xx. It’s obvious in hindsight and it was not obvious at all in advance, and it’s the clearest lesson the project has taught me — retry semantics are a property of the error, not of the endpoint.
Let the database say no
The application checks that two coaching sessions don’t overlap. That check is a race, always, no matter how carefully it’s written, because two requests can pass it concurrently before either commits.
Postgres will just do this for you:
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE app.coaching_sessions
ADD CONSTRAINT excl_coaching_sessions_overlap
EXCLUDE USING gist (
contact_id WITH =,
daterange(window_start, window_end, '[]') WITH &&
) WHERE (status IN ('PENDING', 'CONFIRMED'));
The application check stays, because it produces a friendly error message at the right moment in the UI. The constraint is the thing that’s actually true. I’ve come round to thinking this is the correct shape for most validation: the readable error in the app, the authority in the schema.
The same instinct runs through the rest of the schema — seventy-odd numbered migration files, currently up to 0193. Images are content-addressed by SHA-256 and stored as bytea, so uploading the same photo twice is a no-op; they’re resized and encoded to both JPEG and AVIF in-process, on the Pi, which is slower than I’d like and has never been slow enough to matter.
My favourite migration is 0130_remove_passwords.sql, which drops six columns and is the entire content of the file. Login is passkeys, or a six-digit code by email. Nothing to leak.
Two ways to get email wrong
Until three weeks ago, sending mail went straight to SMTP inline, and it managed to be wrong in two opposite directions at once.
Most call sites sent from a detached goroutine and logged the error. A momentary SMTP outage meant a user silently never learned their booking was approved — no record, no retry, no way to answer “did they actually get told?”.
The Stripe webhook did the opposite: it returned the send error up, so a failed email failed the whole webhook, and Stripe retried the hook and re-ran every side effect around it. A bounced email could void an invoice twice.
Both are the same bug — treating delivery as part of the transaction that caused it. The fix is a transactional outbox: write the intent to a table, drain it from a worker with backoff and a max attempt count, and key rows on an idempotency key so a redelivered webhook produces one receipt rather than two. Delivered rows are kept for ninety days rather than deleted, because “did that user get told?” should be a query.
The thing that stopped running
For eleven days in July, every page on the site told Google it was a duplicate of the homepage.
The SPA ships one generic <title>, description and canonical in index.html, and rewrites them in JavaScript once the router resolves. That’s invisible to anything that doesn’t execute JavaScript, so to fix it for crawlers there was a Cloudflare Pages Function — site/functions/_middleware.js — which filled the head in server-side. It worked. It had worked since the day I wrote it.
Then on 15 July I moved site hosting off Pages and onto the Pi, so the site would deploy by rsync and sit behind the same cache rules as everything else. That was a good change and I’d make it again. It also switched the middleware off, because a Pages Function is a thing Pages does, not a file that runs wherever it happens to sit.
Nothing errored. Nothing logged. site/functions/_middleware.js stayed in the repo looking exactly as maintained as it had the week before, and every page quietly went back to shipping <link rel="canonical" href="https://soaringspirit.ch/"> — each one declaring itself a duplicate of the homepage.
Eleven days is about as cheap as this class of bug gets, and I only caught it because I was planning the host merge and went to check what the middleware would do afterwards. What bothers me isn’t the eleven days, it’s that nothing could have caught it. There’s no test for “this file is still in a position to execute”. The deploy got faster and simpler, and what broke was a capability rather than a line of code.
The fix folded into a merge I wanted anyway. api.soaringspirit.ch and soaringspirit.ch were two processes from the same binary on the same machine behind the same tunnel, and the split existed for no reason beyond history — while costing a CORS middleware, a preflight round-trip on every non-simple request, and a sitemap on the wrong host. Moving the API under /api on the site origin deleted the CORS code entirely and let the head for /trip/:id be rendered from a direct database query instead of an HTTP call to ourselves.
Sixteen route collisions had to be resolved to do it, and http.StripPrefix meant all 110 route registrations stayed exactly as written. Everyone gets the same correct head now — no user-agent sniffing, because varying HTML by user agent would fragment the edge cache and there’s no upside.
What I’d tell myself at the first commit
Write the integration tests before the third feature, not after the thirtieth. They’re the only reason the no-framework approach is defensible, and I built up months of debt before I understood that.
Push correctness into the schema earlier. Every exclusion constraint and foreign key I added late replaced application code I’d been quietly trusting.
And when you move a platform, inventory what the old one was doing for you. Migration notes list what you’re carrying across, and I wrote good ones. What they don’t list is the work the old platform did for free, because nobody had to think about it while it worked — that’s exactly the part that leaves no diff, throws no error, and shows up weeks later in a search result. A file can keep being correct long after it has stopped being reachable, and those two things fail completely independently.
The company is sixteen months old and the software has never lost a booking. For a first attempt at both, I’ll take it.