This post contains Railway referral links. If you sign up through one I get a bit of credit.
I build Old Light, a real-time strategy game that runs in the browser. Claim stars, grow an economy, send fleets, all while other players and NPC empires do the same. The second a build finishes or a fleet lands, the server pushes it to every connected client over a WebSocket.
That last part, a long-lived server holding an open socket, rules out most of the usual hosts. Here's what it ruled in.
Why not Vercel or Netlify
Serverless shines when your backend is stateless functions. It's the wrong shape the moment you need a socket that stays open: socket.io wants one process that lives for the whole session, and serverless boots per request and then freezes. You can bolt on a managed WebSocket service, but that's a second system to run and pay for.
Railway runs your service as a normal long-lived process, so socket.io just connects. Fly.io does this too with more knobs to turn. I wanted to ship, so Railway won.
Monorepo, two services
Old Light is an npm workspaces monorepo: a shared types package, an Express plus TypeORM plus socket.io API, and a Vite web app served by a small Express server. On Railway that's two services on the same repo, each with its own root directory and build command, shared built first.
They deploy as separate origins, so the web app reads the API's URL from VITE_API_URL. Vite bakes that in at build time, so it's a build variable, not a runtime one. Postgres is a plugin that injects DATABASE_URL, and production runs migrations rather than synchronize. WebSockets need nothing special until you run more than one instance, at which point you'd add a Redis socket.io adapter. I haven't left a single box yet.
A healthcheck that stops version skew
Two services don't go live at the same instant. Push a commit that touches both, the web finishes first, and for a minute your new frontend is calling API routes that don't exist yet. It 404s, then heals itself once the API catches up, which makes it miserable to reproduce.
The fix is to have the web service report unhealthy until the matching API is live. Both services hash their RAILWAY_GIT_COMMIT_SHA into a short build id, and the web /health fetches the API's health and compares:
const body = await fetchApiHealth(API_URL);
if (body.buildId !== EXPECTED_BUILD_ID) {
return res.status(503).json({ ok: false, reason: "build id mismatch" });
}
res.status(200).json({ ok: true });
Point Railway's healthcheck at /health and the new web deploy waits at 503 while the old one keeps serving. When the API lands on the same build id, health flips to 200 and Railway cuts over. Not a true atomic deploy, but the frontend never serves traffic against a mismatched backend.
Things that only break in production
A few that pass every local test, because dev serves from source and prod serves from a build:
-
Build order.
sharedhas to compile before the API or web, or you build against a package with nodist. Put it in the build command instead of trusting install order. -
Non-TypeScript assets.
tsconly emits.js, so anything you read from disk at runtime (for me, markdown that renders to pages) needs its own copy step or the container hitsENOENTon first read. -
Behind the proxy. Railway terminates TLS at its edge, so set
trust proxyorreq.hostnamereads the wrong host, and add thecompressionmiddleware yourself since the edge won't gzip your HTML.
Cost
About six dollars a month, more than the traffic needs because I keep headroom for spikes. For a stateful, real-time app it's been the least fussy host I've used: the monorepo builds without special-casing, and the commit SHA Railway exposes is what made that version-skew healthcheck possible in the first place.
The deeper backend story behind the game (no tick loop, combat as a pure function, built for scale) is its own post: Browser strategy game backend.
The game running on all this is Old Light, playable in your browser right now. Want to try Railway? Here's my referral link.
Top comments (0)