The speed test that started it — and where it ended up
The speed test was the reason the whole thing started. Before there was a VPN, a Pi, or any of the rest of it, the plan was just this: run my own speed test — LibreSpeed — on a cheap public server so I could check a connection from anywhere without landing on an ad-choked page. Everything else grew around it. So it’s a little funny that of everything on the server, the speed test is the part that matters least now — and the story of how it got there is the point of this part.
The clever part that didn’t work
The first version was more ambitious than a plain speed test. Anyone can report a download number; the genuinely useful thing a speed test can tell you is how a connection behaves under load — whether latency stays low while the pipe is full, or balloons the moment you actually use it. That second behaviour is what makes calls stutter and games lag on a “fast” connection, and it’s worth far more than a raw megabit figure. So I built a custom layer on top of the basic engine to measure exactly that.
It didn’t work, and the way it didn’t work is the interesting part.
Measuring loaded latency from inside a browser turns out to be genuinely hard. My download-direction figures came out consistently too high — the connection looked worse under load than it really was — because the latency probes were riding the same kind of browser request that carried the bulk data, so their overhead inflated the timing. I was measuring my own measuring tool as much as the connection. Upload measured accurately; download was systematically wrong.
Doing it properly would have meant rebuilding the measurement from scratch — a real piece of engineering, and then something to maintain — for a personal tool measuring my own already-understood connection. It wasn’t worth it. So I tore the custom layer out, leaving the plain engine doing throughput and idle latency, which it does reliably. The clever part was the whole reason I’d built more than the basics, and it was the part that had to go.
The irony came later: the reason download-loaded latency is so hard to measure is the very thing I was trying to measure — the connection buffering under load. When I eventually benchmarked the server’s link with a proper purpose-built tool, it showed up cleanly in one run. Some measurements look simple and aren’t, and the honest move when your own version measures wrong is to reach for the tool built for the job, not to defend the one you made.
Cutting it back to its real size
What was left after the custom layer came out was small, and I made it smaller still. The engine can run in a memory-only mode that keeps nothing on disk — no database, no stored history, each test independent. Since I wasn’t keeping results, the database had only ever been there because the fuller install came with one, not because the job needed it. Gone.
This is the same lesson as sizing the box in Part 2, from the other direction: there I bought more server than I needed; here I ran more software than I needed. Both came from provisioning for an imagined requirement instead of the real one. What remained uses a trivial amount of memory and has almost nothing to break — no database to corrupt, no state to reset.
The failure that looked like success
The speed test then gave me the most quietly annoying bug of the whole build, because it failed without looking like it had failed.
The page loaded. The layout was right, the button was there, everything appeared fine — but the test wouldn’t run. No error, no obvious clue, just a control that did nothing when pressed. A visible error would have been easier; this looked like success and behaved like failure.
The cause was a single file the test relies on to do its work in the background. That file is requested by the browser as a relative path, and the web server in front of the service wasn’t set up to hand that specific path back — so the browser asked for it, got nothing useful, and the test silently had no engine to talk to. Everything the eye could see had loaded; the one piece it couldn’t see hadn’t.
The lesson stuck because it’s a general one: “the page loads” is not “the thing works.” The parts you can see rendering tell you almost nothing about the parts that run out of sight. Once I knew to look for that missing piece specifically, the fix was small — but I lost real time to it precisely because nothing announced itself as broken. A silent failure is worse than a loud one; it costs you the time you spend not yet knowing there’s a problem.
The bigger question: should this be public at all?
With it working — as a plain throughput test, the loaded-latency ambition abandoned — I stepped back and asked a question I should probably have asked at the start: is a self-hosted public speed test actually worth running?
The honest answer was no. A single box in one city, measuring against itself, is a worse tool than the free public testers that already exist — ones with servers around the world, and ones that measure the loaded-latency behaviour properly, the exact thing my own version had failed to. Having concluded I couldn’t measure the most valuable part reliably, it made no sense to publish a test that only did the easy part while better tools did the hard part well. Hosting my own inferior version of a solved problem wasn’t teaching me anything more or serving anyone better. It was just running because it was the thing I’d originally set out to run.
That was an uncomfortable but clarifying conclusion, because it meant the founding feature of the whole build didn’t deserve its public spot. Keeping it public would have been sentiment, not reasoning.
Where it ended up
So I moved it. The speed test came off the public web entirely and became a private tool, reachable only over the VPN tunnel from Part 1 — bound to the tunnel, not the public internet, so it answers only to my own devices when they’re connected. The public address it used to occupy went to something more useful instead.
That handed me one last small lesson, of the kind this build keeps producing. Once the service was bound to the tunnel’s private address, it started failing to come up after a reboot — because that address doesn’t exist until the VPN itself is up, and on a cold start the speed test was trying to launch first, into a network that wasn’t ready for it. The service and the tunnel had an ordering dependency that only showed itself at boot. The fix was to tell the system explicitly to bring the tunnel up first and only then start the speed test. Nothing about the service was wrong; it was starting at the wrong moment. Boot ordering is one of those things that’s invisible until a reboot exposes it, and then obvious in hindsight.
There’s a neat symmetry to where it landed. The speed test was the reason for the public box, and it ended its life on the public box as the one thing that isn’t public — a private utility I can reach from my phone over the tunnel when I actually want it, which is rarely. It didn’t fail. It just found its real size, the same way the server plan and the software stack did: smaller than I first imagined, and better for it.
That’s the pattern this whole build kept teaching, service by service: start with what you assumed you needed, then let reality cut it down to what you actually use. Next in the series, the tunnel itself — the VPN that quietly became the most important thing on the box.