Case study
A one-page concept, taken to a multi-region game-hosting platform.
A game studio came to me with a pitch deck and no platform team. Six months later the platform was live across two regions. I led the architecture and the build. One other engineer took the frontend and parts of the control plane. The code is theirs and stays private, so what follows is the part I can show: how it actually works, and why I built it this way.
The short version
What it is, in plain terms
MetricHost lets that studio sell server hosting to their own players. And make money doing it.
Here is the problem the whole thing is built around. An idle server costs the same as a busy one. A game server with nobody playing on it still holds its full slice of memory, and memory is what you pay for.
So the platform puts idle servers to sleep. It wakes them the second a player joins. The player never notices the gap. That reclaimed capacity, the memory handed back while a server naps, is where most of the margin lives.
The rest is table stakes for a real product. It runs across two regions, so players reach the nearer one. Each customer's data stays walled off from the next. It holds up under real load.
Then the hosting market shifted and the numbers changed. I gave the studio the honest math instead of quietly running up their bill. The rest of this page is the detail, for anyone who wants it.
The problem
A pitch, and no one to build it
The pitch was a one-page concept and a list of games to run. Minecraft, Valheim, Rust, that kind of thing.
Under the pitch was a much bigger job. A hosting platform is a real distributed system: many machines, two regions, servers appearing and vanishing all day. And there was no one in-house to build it.
The obvious shortcut is an off-the-shelf panel. It doesn't get you there. Pterodactyl and AMP are monoliths, which means one process runs everything and you can't scale a single piece on its own. Worse for a hosting business, they leave idle servers running at full cost. That idle cost is the line between making money and not.
So they brought me in to design and build it, with the one other engineer on the frontend and parts of the control plane.
The shape of it
Five planes, each with one job
Start with the player. Their connection skips the application entirely. Game traffic runs straight through a fast TCP proxy to their own server. So nobody waits behind someone else's billing request.
The dashboard and the APIs are a separate layer. One gateway sits in front of nine backend services. A small control plane, written in Go, sets up new capacity on its own. Operators get their own console. Underneath all of it sit the databases, a cache, an event stream, and file storage.
It all runs on self-hosted Kubernetes (k3s), across two regions, with nothing exposed to the public internet. Traffic comes in over Cloudflare tunnels instead.
Inside the cluster, who can talk to whom is decided by the workload itself, not its IP address, and enforced down in the operating system. A hacked game mod can't even open a connection to the billing service.
I chose self-hosted Kubernetes over a managed cloud service for one stubborn reason. The managed offerings won't let a machine use disk as overflow memory. And that overflow is exactly what the cost savings below depend on.
The decisions
Six calls, and what each one cost me
The parts list tells you little on its own. What matters is what I chose, what each choice cost, and whether the trade held up in production.
Idle servers wind down in stages instead of running flat out.
A server sitting empty holds onto the same memory as a full one, and for a hosting business that idle memory is just margin you're burning. So empty servers wind down in steps. First the CPU drops to almost nothing while the memory stays put, which wakes back up in well under a second. If a server stays empty longer, its cold memory is moved out to fast local disk while the server keeps running, which wakes in a few seconds. Either way, the freed-up memory goes back to the pool for a server that's actually busy.
TradeoffEach step frees more memory but takes a little longer to wake, so every game sets its own limits: how quickly its servers wind down, and how far. Players never notice. When someone reconnects, the proxy holds the connection and buffers their first moves while the server comes back.
Adding a server is a workflow that survives a crash.
Bringing up a new game server isn't one step, it's several: reserve a machine, boot it, join it to the cluster, lock it down, mark it ready. A control plane written in Go runs that whole sequence as a durable workflow (Temporal), so if it crashes halfway through, it picks up exactly where it left off instead of stranding a half-built server that nobody cleans up. It decides when to add servers from real demand, the ones players are actually waiting on, so a quiet hour spins nothing up. And it keeps one spare server built and ready, so when a rush arrives all at once, the first players in aren't stuck waiting for a cold machine to boot.
TradeoffAnything that can spend money on its own needs guardrails: a budget ceiling, a hard cap on how many servers it can start, a cooldown, a limit on parallel builds. Plus a background job that cleans up stray servers, but only ever the ones the control plane itself created. The always-on baseline is tagged differently, so the cleanup can never touch it.
The regional database is the truth. The global index is best-effort.
Services don't call each other directly to get work done. They post events to a shared stream (Redpanda) and move on, so something like a user deletion spreads to every service on its own. With this many services, that's the only way GDPR erasure stays manageable.
TradeoffThere's a catch worth being honest about. Gateways find a server by reading one shared, global index, but each region has its own separate event stream, so a server created in one region doesn't show up in that index on its own. To close the gap, the regional service writes to the index itself. It does this deliberately as a best effort: the write only happens after the server is already saved safely in its own region, and if the index write fails, it's logged and dropped, never rolled back. The reasoning is simple. A shaky lookup table should never be able to erase a server that really exists. And a missed write fixes itself: until the index catches up, gateways just can't route players to that server yet, and the server's next status change tries the write again.
The admin console is slow on purpose.
Operators get their own console and their own API, walled off and read-only by default. Two database connections back it: one that can only read, one that can write to exactly two tables. The dangerous moves, impersonating a user or issuing a refund, take two people: one to ask, a different one to approve.
TradeoffMore friction for the operator, which is the point. You can't approve your own request, the request expires, and every write needs a fresh second factor. For a console that can reach into a customer's account, that's a trade worth making.
US traffic stays in the US region.
Console, RCON, the file manager, live metrics: they're interactive, and a hop across the Atlantic on every action feels broken. So per-server operations go straight to the region that owns the server. The frontend looks up where a server lives and routes there directly over Cloudflare's edge.
TradeoffThat splits the world in two: each region is its own data plane, but auth, billing, and the registry stay global so a session works everywhere. The seam between them was the thing I had to get exactly right, which is the cross-region write two decisions up.
Moved the network to Cilium eBPF, carefully.
k3s ships with flannel, which enforces network rules through long iptables chains that get slower as you add services. Cilium does it in the kernel with eBPF, keyed on which workload is talking instead of which IP. That's what makes a rule like "game servers can't reach billing" both cheap and enforced in the datapath itself, where a written rule and a live rule are the same thing.
TradeoffSwapping the network layer can take the whole cluster down with it, so I rehearsed the full cutover on a throwaway cluster first (routing, DNS, policy, pod-to-pod) before going near production, then cleaned up the dead flannel interfaces. kube-proxy stayed in place and kept doing its job. The win I was after was the policy datapath.
What shipped
The honest numbers
Engagement facts. The full architecture lives in the public repo.
How it ended
The wind-down worked. Then the price changed.
The wind-down did its job. Idle memory goes back to the pool. On the platform's own math, that is the difference between barely covering costs and reselling the reclaimed memory as headroom.
Then the vendor raised the price of its machines overnight. That is a different kind of problem, and freeing memory does nothing about it. Cheaper-per-server helps when memory is the cost. It does not help when the machines themselves cost more.
So what did I do about it? The math, and the call it led to, is its own write-up below.