Fungi grew up on Cloudflare. The Hub, the web app and API behind my.fungi.computer, is a Worker. Every Team and every agent is a Durable Object: a small program with its own database that wakes up when it’s needed. So is every Computer. Its files live in one Durable Object, and whatever runs code against those files is a separate question (more on that below). It was a great way to start. We wrote almost no infrastructure, and honestly, it’s how I think about programs now.
In September we decided to move nearly all of production onto one server we rent, called botanical-hel1. It runs celld, Deno’s self-hosted runtime for Workers and Durable Objects. Almost all of the same code runs on both. What changes is who you pay, and how.
This note is about the line we drew. “Leave the cloud” is a slogan. The line is the actual decision.
(The game engine’s side of this move, with the fleet numbers, is on the Vishnu page.)
Start with the line on the bill that hurts
It started with the games. Our game engine, Vishnu, keeps each world in a Durable Object that ticks ten times a second. Cloudflare charges per database row written, and in our first design an active world wrote about 1,500 rows a second. We did the math for one world running around the clock: about $3,900 a month. With a smarter storage design, about $145. Those are planning estimates, not invoices, but even $145 is a lot for a free browser game.
On celld, database rows are free. A world pays for a slice of a box, plus the writes to object storage that make it durable. Batching those writes across a three-node fleet got it down to about half a write per game step. That’s roughly $0.006 an hour per active world at R2’s prices. Easy call for the games.
Then Levi said the obvious thing: “It’d be great to run most of our main stack on it too if possible.” The Hub, the Teams and the agents are the same kind of code. Within days that turned into a rule at the top of every plan we write: production moves to the box, and nothing new gets deployed as a Cloudflare Worker.
Same bytes, or it doesn’t count
“Workers-compatible” is a claim, so we tested it before trusting it. We ran the exact same built bundle on workerd (Cloudflare’s open-source runtime) and on celld, and compared what happened. The game host matched three runs out of three, and then again on a three-node fleet.
That kind of test pays off at the edges. It turned up one difference: how fast an App’s in-flight request stops when you disable the App. We fixed that in the celld build we run and reran the same test until it matched, before any App code ran on the box. How to isolate customers’ App code there is still its own open decision.
The line
Here’s what moves to the box:
- The Hub, plus the Durable Objects behind it: Teams, agents, and each Computer’s Workspace (the Durable Object that holds a Computer’s files).
- The heavy side of Computers. Light work, like files and a just-bash shell, runs in a Dynamic Worker next to the Workspace and needs no machine at all. When an agent needs real processes, the Computer plugs in something heavier: a Cloudflare container, or a Firecracker VM on the box. Requests between the Hub and the VM host are signed in both directions. The VM is just one option. The Computer is the Workspace, and it outlives whatever ran its last command.
- Postgres and pgbouncer, with the database’s change log (WAL) archived every minute, a full base backup every day and a daily encrypted dump.
- A watchdog that checks the services, the database, disk, memory, backups and the tunnel every minute. It can restart a stuck service, with a limit on how many times, but in production it only watches until we switch over.
- Vishnu, the game site and the engine host, served as one Worker.
And here’s what stays on Cloudflare, on purpose: DNS and TLS at the edge, email sending, AI, image resizing, rate limits, and Artifacts (the Git repos that hold Teams’ code). For each of those, celld has nothing equivalent, or Cloudflare is just the sensible place to run it.
The list is a contract
The important part is how we reach the stuff that stayed behind. There’s exactly one Worker left in front of the Hub, called hub-edge, and it does two jobs:
- It forwards the public hostname to the box through a tunnel. Streaming responses, redirects and WebSocket upgrades all pass through. It strips any forwarding, access or signature headers a client tries to sneak in, and passes along the real client IP from Cloudflare’s own header. If the box is unreachable, visitors get a plain “Hub unavailable”, not a tunnel error page.
- It offers six operations the Hub can call, and only six:
email.send,ratelimit.limit,media.generate,images.fit-reference,artifacts.controlandartifacts.git. Every call is signed (HMAC) over its method, path, body and a timestamp that has to be within a minute, and then checked against a schema before anything runs. Git packs are the one exception to signing the body: they stream, so the signature covers the request and the pack flows in behind it.
Inside the Hub, those operations are ports, meaning one interface with two possible plugs. Deployed on Cloudflare, the Hub uses email or AI directly. On celld it calls the same port, and the port just happens to cross the edge. The Hub picks one from its config, and a half-filled-in edge config is an error, not something it quietly skips. The code that sends an email doesn’t care which side it’s on.
The list has grown by two since I first wrote this: Artifacts joined a day after the first four. That’s the system working. Adding an operation meant changing a typed contract, a signature check and a schema, in a review someone actually read. Nobody snuck a binding in sideways.
One small example of the cost of leaving: celld has no rate-limit binding. So the game site’s public limits became simple counters in memory, per time window. Joining and watching a world are counted per world, and each world is one Durable Object, so those counts are exact. Creating a world is counted per visitor. It’s a small thing, but it’s a thing we own now that we didn’t before.
If you’re thinking about the same move, here’s my advice. Don’t start from “leave the cloud”. Start from the line on the bill that hurts. Prove the runtime with the exact same bytes on both sides. And make the list of what stays a contract, with a fixed set of verbs.