Experiment
Go as a Universal Runtime
· Claude Code
We got early access to the Rust runtime on Laravel Cloud, so we attempted to get DHH's new Rust port of Campfire deployed. We dropped the idea into Claude Code and it went to work. The experiment was a success, but one of the headlines was a surprise. The AI missed the first sentence in the instrutions... that we had access to the not-yet-released Rust runtime. So failing to find a Rust runtime via API / CLI probes and the docs, it just set out to find a way without one.
The solution that Claude landed on was obvious in hindsight, but I would never have thought of it. It added a fake go.mod file, tricking Laravel Cloud into thinking the project was a Go project, and setting up the application. Since the runtime's start command is ./app the agent theorized that any binary on that path, regardless of language, would work. It did.
I read this in the report and chuckled. My response to Claude was "So theoretically, we could use this trick to drop any binary in that app, essentially turning the Go runtime into a universal runtime." Ten minutes later, the experiment was underway. Overnight, a fleet of agents created and deployed 38 environments serving a basic HTML page from binaries written in a bunch of different languages.
It's important to note that this works today, but we have no reason to believe that Laravel intended for Cloud to be used this way. So I definitely wouldn't take this approach with a mission-critical C# microservice.
We have left the original writeup from this experiment by Claude Code below in case it is of interest to people who have a real interest in the languages and frameworks that appear in the writeup. We are only well versed in a handful of these, and all of the work done in this experiment, after the intial planning and setup of the main branch using the Go runtime, was done by Claude Opus 5.5 with no input from us.
Short version: it generalises. Getting 37signals' Rust port of Campfire onto Laravel Cloud needed a way to run a binary the platform had no runtime for, and the way through was the Go runtime, whose start command is ./app — a file path, not a language invocation. That write-up ends with an obvious question we did not answer there: is that specific to Rust, or will it carry anything?
Anything. Thirty-eight Laravel Cloud environments are live, each serving the same "Hello from {language}" page from its own native linux/arm64 binary. One of them is COBOL. One is Forth. One is aarch64 assembly with no libc at all. None of them is a container image, and on thirty-six of the thirty-eight the only Go in the repository is an empty go.mod.
The repository is public at artisan-build/hello_cloud, and the index that links every page is live at hello-cloud-production-kgvqnk.laravel.cloud.

The index is the main branch: a real Go module, compiled by Cloud's own Go build command, rendering a tab-separated file. It is the honest control — the one environment in the experiment where nothing clever is happening.
This started the way the good ones do. Ed's framing was "burn some tokens on something totally useless but fun," and the plan survived first contact with the cost question only because he answered it in advance: "If they don't auto-hibernate, exploring why that is will be beneficial to everyone, even if nobody would ever do this in real life (someone definitely will)." So thirty-odd always-on environments stopped being a budget gate and became a measurement.
The hypothesis, stated so it could fail
Campfire's launcher was 232 lines of Go that exec'd a Rust binary. We were proud of it. The question underneath it was never tested: does the Go runtime care that there is Go?
So phase one ran one worker on one hypothesis, with a fallback ready:
A
go.modat a branch root, plus a build command whose only job is to leave an executable at./app, is enough. No Go code, nogo build, no shim.
The fallback was cmd/shim/main.go, a ten-line Go program that would exec the real binary if Cloud insisted on compiling something. It is still committed. It was never needed, and it has never run on Cloud, so we are calling it unproven rather than working.
The proof is three lines of a deploy log. The rust environment's build command is sh .cloud/fetch-binary, which downloads a binary from a GitHub release, checks its SHA-256 and marks it executable:
$ Running build command
sh .cloud/fetch-binary
fetch-binary: bin-rust was built from a6d762df22333b07fd2a2c57c257576f87a9b944
app: OK
-rwxr-xr-x. 1 www-data www-data 725784 Oct 1 20:15 app
fetch-binary: ./app is ready; Cloud's Go runtime will start it.
4.3 seconds of build, no compiler invoked, and the page was serving 44 seconds after the deployment started. Nothing checks that go build ran, or that ./app is a Go binary. Cloud simply starts ./app relative to the application path.
Two details decide whether this works for you, and both are about when:
Detection is per environment, and it runs once. The application is created against the default branch, so the branch you send to POST /api/applications is ignored. Each later environment detects from the branch you name it, and nothing re-detects afterwards. The go.mod has to be on the branch before the environment exists.
The start command is fixed at creation and editable afterwards only in the dashboard. The build command is editable over the API. Get the first one wrong and you need a human.
The pipeline, and why the binaries are not built on Cloud
Cloud's build step has a 15-minute cap, cannot run apt (it is not root), and ships a Go toolchain and nothing else. Thirty-eight language toolchains were never going to fit in there. So nothing is compiled on Cloud except the two controls.
main ───── the index, the shared page template, the OG card generator,
│ the CI workflow, tools/sfx
├── rust ──── lang.json + Cargo.toml + src/main.rs
├── cobol ─── lang.json + hello.cob
├── forth ─── lang.json + hello.fs
└── … 34 more, each branched from main
Every language branch carries one lang.json naming its toolchain as data:
{
"language": "Zig",
"slug": "zig",
"framework": "std.http.Server",
"image": "debian:bookworm-slim",
"build": "… leave an executable at ./app …"
}
GitHub Actions reads it, generates that branch's OG card with main's generator, runs the build inside that image on a free arm64 runner, smoke-tests the result on debian:12 — the same OS image Cloud runs — and publishes app plus its SHA-256 and the commit it came from to a rolling release. Adding a language needs no CI change at all: the toolchain is a string in a JSON file.
On Cloud, thirty-six of the thirty-eight environments share one build command, character for character:
sh .cloud/fetch-binary
It reads the branch from the environment, waits up to ten minutes for the release asset whose recorded commit matches the one being deployed, verifies the hash, and leaves it at ./app. Losing that race on purpose is what makes push-to-deploy safe: one git push starts the Actions build and the Cloud deploy at the same moment, and the Cloud build just waits. No Cloud API token exists anywhere in CI.
The timings, measured rather than estimated:
main push → live (Cloud compiles the Go) |
49 s |
| A language branch, push → live through the whole pipeline | about 2 minutes (118 s and 124 s on two measurements) |
| — of which Actions | 42–62 s |
| — of which GitHub release CDN lag before the asset was visible | about 50 s |
| Cloud's 15-minute build cap used, at worst | 11% |
That CDN lag is the reason fetch-binary polls instead of checking once. The Actions run finished about fifty seconds before the asset was downloadable.
Choosing thirty-eight languages
The selection was not a top-38 list. It was six batches, each picked to stress a different part of the claim, because "any binary" is only interesting if the awkward shapes are in the sample:
- a — systems: C, C++, Odin, V, D, aarch64 assembly. The floor. If a hand-rolled socket server in assembly works, nothing about the binary matters.
- b — functional: Haskell, OCaml, F#, Gleam, Roc, Racket. Heavy runtimes, unusual linkers, and in Racket's case a distribution that is a directory tree rather than a file.
- c — BEAM and JVM: Elixir, Erlang, Java, Kotlin, Scala, Clojure. The hardest packaging problem in the set: every one of these ships a virtual machine, and the pipeline moves exactly one file.
- d — modern compiled: Deno, Bun, Nim, Dart, C#, Crystal. Languages that already have a "compile to one executable" story, to see whether the easy case is actually easy.
- e — vintage: Pascal, Common Lisp, Ada, Fortran, COBOL, Forth. Chosen for fun, and they earned it — four of the six have no web framework worth the name, so they are hand-rolled HTTP over libc sockets.
- f — miscellaneous, plus one control: Lua, Perl, Swift, Julia, and
rust-native.
That last one is not a language entry so much as an experiment inside the experiment. Campfire's Rust app on Cloud's native Rust runtime had apparently never hibernated across 8- and 25-minute idles, while the three Go-runtime environments here hibernated exactly as expected. Two suspects: the runtime, or Campfire's own process shape. So batch f built the same hello page as a plain Cargo project with no go.mod, got a native Rust environment, and measured it. That is the only reason Rust appears twice in the table below.
Two standing rules shaped what got built. Ed's: "For any of these languages that has a simple web framework that we can use, let's use it. For any that don't, but we can create a simple binary that just serves a static html page, build it." And a per-language soft cap of about forty minutes, after which the blocker gets recorded and the batch moves on — because a language that cannot be made to work is a wanted result, and spending four hours on one of them buys a worse report than spending forty minutes on six.
The fan-out
Six workers, one per batch, running at the same time. Three things made that safe, and two of them were learned the expensive way on earlier runs:
Each batch worked in its own git worktree. One shared checkout with six agents switching branches is not a race condition, it is a certainty.
No worker pushed main. The recipe's last step is "append your row to the shared index," and six parallel writers to one file is six-way conflict. Instead each batch returned its rows as text in its report, and they were registered centrally in one pass afterwards.
If a language needed a change to something shared, the worker stopped that language and proposed the change instead of making it. That rule is what produced the cleanest finding in the whole run, and we will come back to it.
Phase one had already written the recipe the batches followed — where binaries get built, how the page and card are shared, the local debian:12 smoke test before any push, the live checks after. Then a single integration pass merged thirty-three rows, fixed the two stragglers and rewrote the README. Then a three-worker fix pass for a bug nobody had been looking for.
Every claim here was re-checked against the live pages afterwards rather than taken from the reports.
All thirty-eight
| Language | Serving with | How it is packaged |
|---|---|---|
| Go | net/http (stdlib) | compiled on Cloud — the control |
| Rust | axum | dynamic, glibc 2.36 |
| Zig | std.http.Server | fully static (musl) |
| aarch64 Assembly | raw Linux syscalls | static, no libc, no interpreter |
| Ada | GNAT.Sockets | fully static |
| Bun | Bun.serve (bun build --compile) |
self-contained bundle, dynamic |
| C | hand-rolled BSD sockets | fully static (musl) |
| C# | ASP.NET Core minimal API (NativeAOT) | dynamic, no managed runtime shipped |
| C++ | cpp-httplib | fully static (musl) |
| Clojure | Ring handler on http-kit | self-extracting ELF (jlink image + jars) |
| COBOL | GnuCOBOL CALL to libc sockets |
fully static |
| Common Lisp | Hunchentoot (save-lisp-and-die) |
self-contained bundle (runtime + heap) |
| Crystal | Kemal | fully static (musl) |
| D | std.socket | fully static (musl) |
| Dart | shelf (dart compile exe) |
dynamic |
| Deno | Deno.serve (deno compile) |
self-contained bundle, dynamic |
| Elixir | Plug + Bandit (mix release with ERTS) |
self-extracting ELF |
| Erlang | cowboy (rebar3 release with ERTS) | self-extracting ELF |
| F# | ASP.NET Core minimal API | self-contained single file, dynamic |
| Forth | gforth 0.7.3 + unix/socket.fs |
self-extracting ELF |
| Fortran | ISO_C_BINDING sockets |
fully static |
| Gleam | wisp + mist (Erlang release with ERTS) | self-extracting ELF |
| Haskell | Scotty on Warp | dynamic (libgmp, libz, libm, libc) |
| Java | Javalin 6 (Jetty) | self-extracting ELF (jlink image + shaded jar) |
| Julia | HTTP.jl (PackageCompiler) | self-extracting ELF |
| Kotlin | Ktor 3 (CIO engine) | self-extracting ELF (jlink image + shaded jar) |
| Lua | Lua 5.4 in a static C host | fully static |
| Nim | std/asynchttpserver | dynamic, glibc 2.36 |
| OCaml | Dream | self-extracting ELF (server + libssl/libcrypto/libev) |
| Odin | core:net | dynamic, glibc 2.36 |
| Pascal | RTL Sockets unit |
dynamic |
| Perl | Mojolicious (PAR::Packer) | self-extracting bundle, dynamic |
| Racket | web-server | self-extracting ELF (raco distribute tree) |
| Roc | basic-webserver | self-extracting ELF |
| Rust | axum, on Cloud's native Rust runtime | compiled on Cloud — the second control |
| Scala | cask (Undertow) | self-extracting ELF (jlink image + shaded jar) |
| Swift | Hummingbird (swift-nio) | static Swift runtime, dynamic glibc |
| V | veb | dynamic, glibc 2.36 |
The sizes span four orders of magnitude. The assembly binary is 78,760 bytes, of which 64,590 are the embedded OG card — the program is about fourteen kilobytes. Julia ships 134 MB and unpacks to 425 MB, and it is the one environment that needed a larger instance than the rest. Deno is 105 MB, Bun 81 MB, Swift 66 MB stripped. C is 118,664 bytes.

COBOL with no C shim. The sockets are CALLs into libc from COBOL, and the HTTP is hand-rolled in COBOL. The brief allowed a tiny C helper and it did not need one.
The self-extracting ELF, invented twice in one night
Twelve of the thirty-eight ship a runtime that is a directory: an OTP release, a jlink image, a Forth engine plus its .fi image and its library tree. The pipeline moves exactly one release asset called app, and CI requires that asset to be a linux/arm64 ELF. Those two facts are in direct conflict.
Two batches, working from the same rule — if it needs a shared change, stop and propose it — hit that wall within an hour of each other and both invented the same answer rather than asking for the shared change: a static C launcher with a gzipped tar of the runtime appended to it, and a trailer saying where the payload starts. Appending bytes to an ELF leaves it a valid ELF, so file is satisfied, CI is satisfied, and nothing in the shared pipeline has to learn a new shape.
Two independent teams reaching for the same hack on the same night is usually a sign that it is the answer and not a hack. It is now one tool on main, and every bundle gets it.
The one that generalises: RLIMIT_NOFILE
This is the finding worth the whole experiment, and it cost the most to get.
Cloud hands the application process a soft file-descriptor limit of 1,073,741,816. About 1.07 billion. The BEAM sizes its port table from that number, so it tries to allocate on the order of a gigabyte before it prints its first line, and the cgroup kills it.
What Cloud reports is that your application ran out of memory and you should increase it. That is wrong, and it is wrong in the most expensive possible way, because it sends you to the one place that cannot help. We failed identically at 256 MB, 512 MB and 1 GB.
The fix is one line, needs no privileges, and takes the process from OOM-killed to 76 MiB resident:
ulimit -n 65536
You can reproduce it anywhere: docker run --memory=256m --ulimit nofile=1073741816.
The timeline here is its own small lesson about parallel work. Batch c found this and wrote it up. Batch b, running at the same time, could not get Gleam to boot and recorded it as a failure — "out of memory" at every instance size it tried — because the two batches never saw each other's reports. Gleam went live later with exactly that one line added, which is as clean a confirmation of the diagnosis as we could have asked for, and a reminder that six workers in parallel is six workers who do not know what the others just learned.
Anything with a VM in it is exposed to this on Cloud today, not just the BEAM.
A pom.xml at the root does not lose — it refuses
Detection does not fall through. Put a pom.xml next to a go.mod at a branch root and Cloud declines to create the environment at all:
The [java] branch of the repository [artisan-build/hello_cloud] uses an unsupported framework. Only Laravel, Symfony, PHP, Express, Hono, Nuxt, Next.js, TanStack Start, Elysia, NestJS, JavaScript, Go, Ruby, Rails, Django, FastAPI, Flask, Python, Rust, Axum, Rocket, Actix Web, and Warp applications are supported.
That error message is, as far as we can tell, the only place the supported list is written down anywhere. It is also a reasonable answer to a genuinely ambiguous repository; it is just an answer you can only discover by tripping it. The JVM branches keep their pom.xml one directory down and everything is fine. mix.exs and rebar.config do not trip it.
The hour that went to a header
Send the Cloud API Accept: application/json and every request comes back 401 Unauthenticated with a perfectly valid token. It is a content-negotiation failure wearing a credentials failure's clothes. Accept: application/vnd.api+json works. An hour went into checking the token before anyone suspected the header.
Cost, and the thing we got wrong twice
Thirty-eight environments is real money if nothing sleeps. The smallest size the API accepts is flex.c-1vcpu-256mb at $4/month in us-east-2, which Cloud's own price list marks deprecated; the current flex-512mb is $6. So the arithmetic ceiling for this experiment is about $120–180 a month if every environment stays awake.
It does not. Every flex size reports scale-to-zero, billing is per second, and these environments are idle almost all the time.
Getting that right took two tries, and the first one was wrong in the platform's favour — twice, in two separate investigations, on two different applications. A wake on Cloud's current Flex sizes is a thaw of a frozen container, not a boot. Same process, same pid, same open files, nothing logged, no slow first byte. Which means the two things both investigations were watching for — a boot line and a multi-second first response — are precisely the two things a working wake does not produce. GET /api/environments/{id} says status: "running" whether the instance is up or asleep.
One series tells the truth, inside the metrics endpoint:
GET /api/environments/{id}/metrics → data.replica_count 1 awake, 0 asleep
Read back over a day on the Campfire environment next door, that series says it had been hibernating all along: twelve sleeps, awake 5,944 seconds out of 17,155, a 34.6% duty cycle — and asleep inside both of the windows that had been written up as "hibernation never fired." The Go-runtime environments here behave the same way, and so does the native Rust one, which settles the question batch f was built to answer: the Rust runtime hibernates too, by freezing. Campfire never sleeping was about that application's shape — specifically its always-connected websocket — and not about the runtime.
We asked a question the evidence available to us could not answer, got silence back, and read the silence as a no. Twice.
The bug a green deploy cannot catch
With all thirty-eight live and verified, the integration pass ran the same sweep one more time with a cache-buster appended — ?cb=… — and it dropped from 38/38 to 25/38.
Thirteen pages 404'd on a query string. Ten returned 404 for both the page and the card; the assembly one 404'd the page but served the card; Roc served the page at /og.png?cb=…; Scala returned 400. The pattern was clean: every hand-rolled server compared the raw request target against a literal /, and no framework-backed one did.
It matters more than it looks for a page whose entire job is to be shared. A link pasted into Slack or Facebook arrives with ?fbclid=… on the end. The index links bare URLs, so the acceptance run was honest and the first real visitor would not have been.
Three workers fixed it in about fifteen minutes each, one branch each, and two of the four framework cases were not the bug anyone expected:
- Scala's 400 had nothing to do with routing. cask binds query parameters to endpoint arguments, and rejected
fbclidas undeclared before the handler ran. The fix is acask.QueryParamsparameter the endpoints never read, which sets cask's own opt-out for unknown parameters. The path matching had been correct the whole time. - Roc was worse than reported: it had no 404 at all. Every path returned the page. The fallback branch served
/for anything it did not recognise, so the query-string symptom was hiding a missing route rather than a broken one.
A sweep of all thirty-eight afterwards: page with ?fbclid and ?utm_source → 200 with the right content, card with a cache-buster → 200 with a real PNG signature, 38/38.
Three things about this are worth more than the fix. CI could never have caught it — the smoke test asks for / and /og.png and nothing else. Cloud's health check asks for a bare /, so a deploy that logs App healthy proves that / answers and proves nothing else; one of these branches logged exactly that while returning 404 to every request we made. And the bug only became visible because thirty-eight implementations of the same spec were sitting side by side. One server with this bug looks like a working server.
What is still open
- Two pages still have no 404. Gleam and Racket serve the page for an unknown path, the same class of bug Roc had. The unknown-path check is 36/38. It was found outside the fix batches' scope and is not fixed.
- The shim is unproven.
cmd/shimexists for the daygo.modalone stops being enough. It compiles. It has never run on Cloud. - Nothing here is load-tested. Every number is from a light acceptance sweep on pages that serve one embedded template.
- The deprecated instance size is load-bearing for the cost figures. Thirty-six of these environments are on a size Cloud's price list marks deprecated. If it goes away, the arithmetic goes up by half.
- It is an unofficial trick. Nothing documents it, nothing promises it, and Laravel could close it in any release. We would rather they replaced it with something explicit than closed it.
What we are taking to Laravel
RLIMIT_NOFILEis 1,073,741,816, and it kills VMs. This is the one with the widest blast radius. Any runtime that sizes a table from the fd limit dies before it logs a line, at any instance size, and the error message sends people to buy more memory — which does not help, and which they will buy. A sane default, or a sentence in the docs, or an error that does not misdiagnose it.- The supported-framework list should be documented somewhere other than an error message. Discovering that
pom.xmlrefuses a branch by hitting it is an expensive way to learn a product's rules. - Expose the sleep state on the environment.
statussaysrunningwhile the instance is asleep, and the only honest signal is areplica_countseries inside the metrics endpoint. Two separate investigations in this organisation concluded scale-to-zero was broken from exactly that gap. One sentence in the docs — your process is suspended and resumed, it does not restart — would remove a whole class of wrong design decisions, and it is a selling point rather than a caveat. Accept: application/jsonreturning 401 is a trap worth closing. A content-negotiation failure should not be indistinguishable from a bad token.- Expose the detected runtime, and let the start command be set. An environment that was detected as Go reports a PHP version and a Node version. The web start command has no API field at all, is fixed at creation, and is editable only in the dashboard — which means an installer cannot set it and getting it wrong is unrecoverable without a human.
X-Forwarded-Protosayshttpon an HTTPS request. Unchanged since the Rails Campfire run. Every absolute URL on these thirty-eight pages is built fromHostwith the scheme hard-coded, because the header whose job is to carry the scheme carries the wrong one.- A "binary" runtime, or a repository Dockerfile. This is the real ask. Everything above is us routing around the absence of a way to say here is an executable, run it. The Go runtime already does exactly that; it just does it by accident, and nobody can build on an accident.
And the credit: deployment logs now return the full build and deploy output over the API. Almost every finding in this write-up came out of a deploy log. The Rails Campfire run lost fifteen deploys to a status string, and that is the single biggest difference between how that experiment felt and how this one did.
Thirty-eight languages is a slow, expensive and faintly ridiculous way to read a runtime's mind. It worked. The things we did not expect to find — a file-descriptor limit that kills virtual machines, a framework manifest that refuses rather than falls through, a query-string bug that thirteen independent implementations shared — are all things that one application, built carefully by people who knew what they were doing, would have hit alone and quietly, one at a time.