Skip to content
Scalpels home

Scalpels is officially launching on October 31, 2026. Special early adopter pricing is available now.

← All ideas and experiments

Experiment

Campfire's Rust port on Laravel Cloud: the long way round

· Claude Code

Editor's Note: The following writeup was written entirely by Claude Code. I would normally rewrite this, or at least tweak it to feel less like AI, but I think there's value in leaving this one as-is. It reveals some things about how I approached the experiment that set it up for failure. It also gives some insight into how, given nothing but a goal, an AI coding agent will try some really off-the-wall things to be successful. Maybe we shouldn't quit reading the code quite yet.


Short version: it works, twice over. 37signals' Rust port of Campfire runs on Laravel Cloud. Two people chat in real time over the app's own Action Cable, with no Reverb and no Pusher shim. Attachments and their thumbnails live in a bucket and survive a redeploy. Search works. The database survives a redeploy through Litestream. It costs about $7 a month against about $33 for the Rails version we put on Cloud last week.

The interesting part is not the destination. It is that we spent the first half of this experiment getting there the hard way, because we had told ourselves Laravel Cloud has no Rust runtime. It does. We had been given early access to it. The sentence saying so was in the first line of our own idea page, and we lost it.

The fork is public at artisan-build/campfire-rust-experiment. It started as a throwaway, built to find out what the minimum change looks like; it ended up with an install section in its README that somebody else can follow. As with the Rails run, most of the work was done by coding agents working from written briefs, with us re-checking every headline claim against the live app.

The premise was wrong, and nothing on the platform could have corrected it

The idea page for this experiment opens with: "We have just been given early access to the Rust runtime on Laravel Cloud."

We read that page back through a web fetch tool that returns a summary rather than the page. The summary kept every constraint — SQLite-only, disk-only storage, libvips, the slow build, one process, websockets — and dropped that one sentence. The brief we then wrote for the first agent said the opposite:

Rust is NOT a listed Cloud runtime (PHP/Node/Bun/Deno/Go/Python, plus Ruby in early access). FIRST, find out how an arbitrary binary can run there.

So the agent went looking for a way to run an arbitrary binary, and everything it could see agreed with the brief. Cloud's runtimes documentation lists PHP, Node/Bun/Deno, Go and Python — Ruby is not on it either, even though the Rails sibling is running on Ruby right now. The CLI has no runtime option at all, because the runtime is detected from the repository when the application is created. There is no per-organisation runtime list anywhere in the docs, the CLI or (as far as we could find) the API.

That is three separate failures and they are worth keeping separate. A summarising tool dropped a sentence. We wrote a negative claim into a brief without a source for it. And the platform gives an agent no way at all to discover what its own organisation is entitled to use, so a careful agent that checks everything available to it still concludes the runtime does not exist. Only the first two are ours. The third is the single most useful piece of feedback in this write-up for anyone building agent tooling on top of Cloud.

Rust on Cloud through Go, which is genuinely a nice trick

Working from the wrong premise, the first agent found the opening in about an hour, and we are glad it did, because the mechanism is a good one.

Go is the only Cloud runtime that is just a compiled binary. Its start command is ./app — a file path, not a language invocation. Push a go.mod to the repository root before cloud application:create, and Cloud detects Go, pre-fills a build command of CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o app ., and sets the start command to ./app. The order matters: the start command is fixed at creation and editable afterwards only in the dashboard.

Then ./app stops being the application and becomes a launcher. main.go is 232 lines of Go whose job is to exec the real Rust binary.

The Rust binary cannot be built on Cloud. Build commands are capped at 15 minutes, the build image has no Rust, no libvips and no ffmpeg, and installing packages into the build image is a Private Cloud feature. So the port's own production Docker image is built locally — about 8 minutes 10 seconds cold, about 2 minutes warm, producing a 266 MB image — trimmed down to a bundle of 50 MB (62 MB once Litestream is in it), and published as a GitHub release asset. The Cloud build command curls and untars that, and compiles only the launcher. Deploys land in 27 to 59 seconds end to end, and the process is serving in about 12 ms from exec.

Two things we learned by measuring rather than reading:

Cloud runs on arm64. Debian 12 containers, aarch64, on Bottlerocket hosts. The agent found this out by deploying a diagnostics binary, because the architecture is not in the documentation anywhere we could find. It is good news — the bundle builds natively on an Apple-silicon laptop with no emulation — but an hour went into hedging about cross-compilation first.

The glibc problem has a clean answer. The binary is built on Debian 13 (glibc 2.41) and Cloud's runtime is Debian 12 (glibc 2.36), which also has no libvips and no ffmpeg. So the launcher does not use the host's dynamic loader at all. It runs the binary through the bundle's own ld-linux-aarch64.so.1 with an explicit --library-path, and ships shell wrappers for ffmpeg and ffprobe that do the same.

One thing that cost the Rails run fifteen deploys cost nothing here: Cloud's network is IPv6-only, and the Rust port's front server already binds [::].

Websockets: the 520 is gone, the headers are not

On 27 September, the Rails run got a 520 on every websocket upgrade and ended up bolting a Pusher-protocol adapter onto Cloud's managed Reverb — 17 new files, with the Reverb connection details set by hand, because Cloud will not attach Reverb to a non-PHP app.

We re-probed. A raw RFC 6455 handshake came back 101 Switching Protocols through the edge, and frames flowed both ways. The 520 is fixed. Thank you.

But the application receives Sec-WebSocket-Key and Sec-WebSocket-Version and no Upgrade header and no Connection header. Our Go probe only got its 101 because it hijacked the socket without checking. Nothing real does that. Action Cable, Go's net/http, ws, Gorilla and hyper all decide whether a connection may be upgraded at parse time, from exactly those two headers. In hyper 1.11.1 the upgrade hook is installed only when they are present.

Which means the bytes have to be put back before the HTTP parser ever sees them. That is what crates/kit/src/front/upgrade_recovery.rs does: 287 lines and six tests that wrap the connection's IO and rewrite the head of an HTTP/1 request carrying Sec-WebSocket-Key without its upgrade headers. It tracks request bodies so it finds later heads on a keep-alive connection, and it stops rewriting the connection rather than guess whenever it cannot frame what comes next — a chunked body, a head over 64 KiB, a connection already upgraded. It is off unless RECOVER_UPGRADE_HEADERS is set, so the default behaviour is byte-identical to upstream.

With that one environment variable, Campfire's own Action Cable works on Cloud. No Reverb, no Pusher protocol, no hand-set variables, none of the 17 new files the Rails branch needed.

A second browser showing a message it never asked for: Ada's realtime probe marker arrives in Bob's room with no reload

Two isolated browser contexts. Bob's window gained Ada's message without navigating. The access log shows GET /cable returning 101 and held open for tens of seconds.

For most of this experiment we believed that was Cloud's edge doing it, and said so in the first report. It is not. We found out much later, on the second app, by reading the nginx config from inside the application's own container: Cloud runs a per-instance nginx in front of every app, and its template sets proxy_set_header Connection "" and never forwards Upgrade at all. The edge passes an upgrade through perfectly well. The stripping happens one hop further in, in a file we could only see from inside. That is a two-line fix plus a map on Laravel's side, and it would unblock every non-PHP realtime framework on every runtime that shares the template.

The same config holds a second trap we have not been bitten by: proxy_read_timeout 20. We held a cable connection open with no browser activity for 90 seconds and the next message still arrived without a reload — but only because Campfire's Action Cable sends a server ping every three seconds, which resets that timer well inside the window. A realtime protocol without a sub-20-second heartbeat would simply be cut.

Also still true, and unchanged since the Rails run: every request arrives with X-Forwarded-Proto: http on an HTTPS request, while Cf-Visitor correctly says https. Campfire survives it because assume_ssl stays on, but anything that trusts that header will build http:// URLs and fail its own origin checks.

SQLite on a filesystem that forgets

Cloud has no persistent volume, and we measured rather than assumed it: a plain redeploy wiped the whole account and brought the first-run wizard back.

Porting the port to Postgres was never going to happen. Its data layer is a reimplementation of Active Record written directly against SQLite, with FTS5 search; moving it means rewriting crates/db and all of search, and it would throw away the port's promise that Rails can boot on the database it writes. For a throwaway, that is not the trade.

So: Litestream, replicating to a Cloud bucket. The launcher restores the database if it is missing, then runs the server under litestream replicate -exec. A failed restore aborts the boot rather than starting an empty database on top of a live replica.

Attaching the bucket is PATCH /api/environments/{id} with filesystem_keys — the CLI has no option for it, which makes bucket attach read as dashboard-only when it is not. Attaching is also what makes Cloud inject the AWS_* variables into a Go app, so no credential was ever set by hand anywhere in this experiment.

After this the database survived redeploys. Attachment bytes did not: the message and its row came back and the thumbnail 404'd.

What it costs

Cloud's own metrics, over the full acceptance suite. Light load only — we ran no load test, so none of this says anything about what happens under traffic.

Rust Rails
Instances 1 × flex-512mb flex-1gb app + flex-512mb Resque worker
Managed resources 1 bucket Serverless Postgres, Valkey 250 MB, bucket, Reverb cluster
Memory, idle → peak 8–9 MiB → 37.6 MiB not measured (hibernated)
CPU peak 1.27 % of a vCPU —
Deploy 27–59 s minutes
Estimated monthly, running continuously ≈ $7 ≈ $33

The four-to-five times difference is real, and it is mostly structural rather than about Rust being fast. One process doing jobs, pub/sub and websockets in-process over SQLite means there is no Postgres, no Valkey, no worker instance and no websocket cluster to pay for, and Cloud charges per managed thing. The per-request efficiency is real too — 38 MiB and 1.3% of a vCPU across the whole suite, on an instance 14 times larger than it needs — but the bill is dominated by the four resources that simply are not there.

The flip side is the same sentence read backwards. That single process is exactly why there is no horizontal scale, and why an ephemeral filesystem is the hard problem.

Both monthly figures assume the instance is up all month. For the Rails app that is what happens; for the Rust app it turned out not to be, and the section on hibernation below is where we found that out.

"There is a Rust runtime"

That was the reply to the first report. We have early access to it, the same way we have early access to Ruby.

The go.mod trick works, and we still think it is elegant. It is also a workaround for a problem we did not have, and we spent an afternoon building it. The untested hypothesis at that point: had the repository root carried only Cargo.toml when the application was created, with no go.mod pushed first, detection would have picked Rust. Testing that became the last act.

"I want something functional"

The second reply that changed the experiment: pixel-for-pixel parity with the Rails app was DHH's success criterion for the port, not ours. Attachments vanishing on every deploy is a feasibility blocker. So the storage layer had to move to the bucket, and we accepted a much larger diff to get there, because the Rust port's storage is not abstracted the way Active Storage is.

What got built was the layer that was missing: a Service enum over DiskService and a new S3Service in crates/storage, with every byte read, write, delete, stat and range behind it. Three things deliberately did not change, and they are what keeps the access model exactly where it was:

  • Blob URLs are unchanged. Active Storage's :amazon service hands out presigned S3 URLs; this one keeps the app's own signed /rails/active_storage/disk/… route for both services and streams the bytes through the app. Same signature, same five-minute expiry, same controller checks. Nothing became readable that was not readable before, and there is no browser-to-bucket CORS.
  • Uploads still land on the app, behind its own authentication gate. No browser ever holds a credential or a presigned PUT.
  • An object's name is the blob's key under one prefix, so nothing had to be migrated and blob objects can never collide with Litestream's replica in the same bucket.

The S3 client is rusty-s3 for signing plus ureq to send the bytes, rather than the AWS SDK. A presigned request carries no checksum header at all, which sidesteps the double-checksum rejection the Rails run hit head-on. The integrity check that Active Storage buys with Content-MD5 is done instead against the source bytes before the object is written, so a mismatch writes nothing — stricter than either Rails service, which lets the bad bytes land and then undoes it.

Variants and posters download the original to a temporary file, run libvips or ffmpeg, and upload under the variant's own key. Whether a variant exists is decided by the variant records, exactly as Rails decides it, not by probing the bucket.

An image, a binary file and a video still in the room after a redeploy, with their thumbnails rendering

The room after cloud deploy wiped the filesystem. An image and its thumbnail, an avatar, the account logo, a video poster and a 64 KiB random file all came back byte-identical by sha256, at the same decoded dimensions, across five redeploys. Deleting a message removed its object from the bucket — 36 objects down to 35. An unauthenticated upload got a 401, and a blob URL with four bytes of its signature changed got a 404.

Twenty-three files, +1604/−265, and eight new S3 tests running against MinIO in Docker.

Four things Cloud's bucket did that surprised us

  1. The bucket's real name is the Cloud filesystem id, not its display name. Every S3 call against the display name comes back Access Denied, which reads exactly like a permissions problem and is not one.
  2. AWS_USE_PATH_STYLE_ENDPOINT is injected falsy for an R2 bucket — the opposite of what the Rails run assumed, and the opposite of what Litestream does in the very same container. Both addressing styles work.
  3. The bucket key comes back in plaintext from the API and from the CLI's --show-sensitive.
  4. An attached bucket needs no application configuration at all. Attaching it is what injects the credentials; the app read them and switched services with zero environment changes. That part is genuinely good.

And two bugs that meant ffmpeg had never worked

Both were ours, both were in the launcher from the first act, and neither had shown up because nobody had posted a video.

PATH was never prepended. The launcher's helper for setting environment variables only writes a variable that is unset — right for HTTP_PORT, wrong for PATH, which the runtime container always has. So the bundle's ffmpeg and ffprobe wrappers never got in front of it and the binary found neither. A missing ffprobe maps to empty metadata rather than an error, so video analysis silently produced nothing, and then posting a video 500'd on a missing previewer. Fixing it also needed the right mechanism: appending a second PATH= entry does nothing, because a duplicate key resolves to the first match at exec.

LD_LIBRARY_PATH was killing every subprocess. With PATH fixed, ffprobe was found and still returned nothing. The launcher exported LD_LIBRARY_PATH pointing at the bundle's glibc 2.41; the wrappers are #!/bin/sh scripts, so Debian 12's own /bin/sh started with that variable, tried to load the bundle's libc, and died with an undefined-symbol error before the first line of the script ran. The variable was redundant — the binary and the wrappers are already run through the bundle's loader with an explicit library path — so it is simply not exported any more.

The honest version of the first act's result, then: "attachments work" was true for images and false for video, and nobody had tested a video.

Doing it again, on the runtime that was there all along

The last act was to build the whole thing a second time on Cloud's real Rust runtime, on a branch with go.mod and main.go deleted. It works, and it is better.

Getting Rust detected is the awkward part. POST /api/applications has no runtime option, and it silently ignores a branch field — so the application came up on main, which still carries the fake go.mod, and was detected as Go. But POST /api/applications/{app}/environments does take a branch, and creating a second environment on the Cargo-only branch came back with build_command: "cargo build --release". That is Rust detection: no dashboard, no support ticket, no flag. The hypothesis held. The vestigial Go environment cannot be deleted, because it is the production one, so it was stopped instead.

That second-environment dance was a consequence of our own repository, not of Cloud. It is gone now, and the next section is why.

Cloud builds our source, inside the cap, comfortably. cargo build --release --locked -p campfire with fat LTO and one codegen unit took 3 m 37 s to 3 m 53 s across four builds. The whole build step — fetching native dependencies, checking out the reference submodule, compiling, installing the launcher — ran in 229 to 245 seconds of the 900-second cap, about a quarter of it. Build to serving traffic is 4 m 30 s to 4 m 43 s. The binary is 35 MB; the deployed artifact, including vendored native libraries and the repository's own submodule, is 434 MB.

There is no cargo cache between builds, and we measured that three ways, including compiling the same commit twice back to back: 223 seconds, then 225. CARGO_HOME/registry is empty every time and the target directory does not exist. PHP gets a composer cache and Node gets a node_modules cache; Rust recompiles five hundred crates on every deploy. This is the single biggest practical improvement available, and it is pure infrastructure.

The native dependencies were the real work. The build image cannot build anything that links a C library — no glib, no libvips, no ffmpeg, no image codecs, thirteen entries in pkg-config --list-all, and apt-get fails because the build runs as www-data rather than root. So:

  • ffmpeg is vendored the way Slate already does it: a pinned static LGPL arm64 build, verified against the release's own checksums at build time, extracted to just ffmpeg and ffprobe. Static means no shared libraries to ship at all, unlike the first app's bundle, which carries six of them.
  • libvips is harder, because the port links against it at compile time rather than calling it as a program. We built libvips 8.16.1 — the same version the production image builds — inside a Debian 12 container with the production Dockerfile's feature set, and shipped it with its shared-library closure minus the 324 libraries Cloud's runtime container already has. That comes to 24 shared objects, 7.2 MB compressed. Before the first real build, an objdump -p sweep confirmed zero remaining dependencies that were neither bundled nor present on Cloud. One linker trap on the way: ld does not search -L paths for a shared library's own dependencies, so -lvips resolved and then every codec symbol libvips references came back undefined. -Wl,-rpath-link fixes it.

A video attachment with an ffmpeg-generated poster frame, next to an image thumbnail generated by libvips

Both of those running on Cloud's Rust runtime: libvips 8.16.1 resized the PNG, and the vendored ffmpeg pulled the frame that libvips then encoded as a 3,666-byte WebP poster. This is the acceptance line the first app never actually checked.

Two deliberate divergences, recorded so nobody is surprised later: the vendored ffmpeg is version 9.0.1 where the production image builds 7.1.5, and libvips is built against Debian 12's codec libraries rather than Debian 13's. Video posters and image variants therefore are not byte-identical to the Rails app's. That is fine for an experiment and consistent with wanting something functional, but it means this deployment path is not a substitute for the production image.

The start command is undocumented and sharp. It is literally $(find /var/www/bin -maxdepth 1 -type f -executable | sort | head -1) — every executable in release/ is copied into /var/www/bin and the first one alphabetically becomes the web process. We learned this because the deploy log prints it unexpanded. There is no Procfile, no API field and no dashboard field to override it. A workspace with two binaries gets an arbitrary one.

Side by side, the native runtime wins on the thing that matters most: Cloud builds our source. The Go-launcher app deploys whatever binary someone last published from a laptop; the commit and the artifact are only conventionally related. The native build is reproducible from the commit alone, needs no Rust changes whatsoever, and needs no custom dynamic loader — the most fragile part of the first design. Its diff against the branch point is 10 files, +573/−308, and not one line of Rust. It costs four minutes a deploy instead of forty seconds, and a 434 MB artifact instead of a 62 MB asset, and most of that would go away with a warm cargo cache.

Memory is the same app with the same shape: 10–13 MiB idle, 50 MiB peak, a couple of MiB above the first app, which is what a dynamically linked libvips and Litestream as the parent process should cost. Same instance, same $7.

One gap on this app at the time: its branch forked before the bucket storage work, so attachment bytes did not survive a redeploy there. That was a merge rather than a rewrite, and doing it is where this goes next.

Making main something a stranger could deploy

The two halves had to meet, and the branch everybody lands on first had to be the good one.

So: the native-runtime branch took the bucket work in a merge, and main was fast-forwarded onto it. Exactly two files conflicted, both modify/delete, and both resolved as deleted — main.go and the script that fetched the prebuilt bundle, neither of which means anything when Cloud compiles the source itself. Nothing was force-pushed and nothing was lost: the old Go-route main lives on as its own branch, and the first app still deploys from it, unchanged.

  • main — the native Rust runtime with bucket-backed storage. Cloud builds it with cargo build --release.
  • using-go-runtime — the go.mod launcher around a prebuilt binary, for anyone without Rust-runtime access.

The merge itself changed no Rust at all — the merged tree's crates/, Cargo.toml, Cargo.lock and test vectors are byte-identical to what main already had — and the one failing test in the suite is the pre-existing one, four video-derived checksums that drift with Debian's ffmpeg build. Attachments then survived two redeploys on the native app with all six blobs byte-identical by sha256 and re-decoded to the same dimensions.

And with main a plain Cargo workspace, application:create gets a Rust environment on the first try. No second environment, no branch gymnastics: the new application's production environment came back with build_command: "cargo build --release", checked twice in two independently created applications. The workaround above is history, which is the single most useful thing in this section for anyone else.

The README, proved by a stranger

An install section nobody has followed is a wish. So one was written, and then a clean room ran it: a fresh clone, a brand-new Cloud application, its own bucket and key, nothing borrowed from either existing app — no variable copied, no id reused — with every step of the README executed literally.

It found five defects, and the worst of them is a trap of our own making:

  1. The repository ships a .cloud/config.json naming its maintainers' own organization and application, and the CLI silently obeys it. Run cloud env:list in a fresh clone and it prints our app — its environments, its masked variables, its deployment ids. Someone outside the organization gets an authorization error and no idea why; someone inside is one cloud deploy away from redeploying our application instead of theirs. The README now opens by telling you to overwrite that file before any other cloud command. Whether a repository should carry that file at all is a question we have not settled.
  2. You cannot create your first application from the CLI if your account has more than one organization. application:create tells you to run cloud repo:config --organization=…; that command refuses until an application exists. The only way through is hand-writing the config file.
  3. cloud usage does not print the organization id, which the first draft claimed. GET /api/meta/organization does.
  4. bucket:create --json reports "keyIds": [] for a key it just created — the key is there, and bucket-key:list shows it immediately, but the id you need for the next step is not in the response that made it.
  5. The commands as drafted would all have stopped on a prompt, and application:create never prints the environment id you need next.

All five were fixed, and then a second fresh application followed the corrected text verbatim, from a fresh clone, with no further corrections: first-run wizard, a message, an image and its thumbnail, a 64 KiB file that downloaded with the exact uploaded sha256, a video poster ffmpeg drew and libvips encoded, an avatar, an account logo, a second user seeing a message arrive with no reload, search finding it — and then a redeploy, after which all six blobs came back byte-identical. Both clean-room applications and their buckets were deleted afterwards.

One honest gap, and it is the first step of the procedure. The clean room cloned rather than forked, because forking would have created a repository in a personal GitHub account that the run was not authorised to touch. Everything after step 1 is observed; "a fork builds" is inferred. The one thing a fork would actually test is whether the pinned libvips URL still resolves — it is a public release asset on this repository, and the README says why it points there, since GitHub does not copy release assets into forks.

The hibernation that was happening the whole time

The first report on the native app said hibernation never fired. uses_hibernation was on with a five-minute timeout; we went silent for eight minutes, then for twenty-five, and both times the same process answered — no new boot: line, no restart, no slow first response. We wrote it up as a platform gap and put it on the list for Laravel.

It was a false negative, and the fault was in the measurement.

On Cloud's current Flex sizes a wake is a thaw of a frozen container, not a boot. The process is suspended and resumed: same pid, same open files, nothing logged. So the two things we were watching for — a boot line and a multi-second first byte — are precisely the two things a working wake does not produce. There is no state to ask for, either: GET /api/environments/{id} reports status: "running" whether the instance is up or asleep, and uses_hibernation and hibernation_timeout are settings, not state. One series, inside the metrics endpoint, is the whole signal:

GET /api/environments/{id}/metrics  →  data.replica_count    1 awake, 0 asleep

Read back, that series says the app had been hibernating all day: twelve sleeps, awake 5,944 seconds out of 17,155 — a 34.6% duty cycle — on an environment nobody was driving. And it was asleep inside both of the windows we had called a failure. It slept at 17:23:42 and woke at 17:27:10, so the probe that ended the "eight minutes of silence" was the wake; it slept again at 17:32:35 and woke at 17:52:48, asleep for twenty of those twenty-five minutes. The first report even recorded the thaw without recognising it — 655 ms and 678 ms of wall time on exactly those two requests against about 200 ms steady state — and attributed the difference to a fresh TLS handshake.

Two things fall out of that, and both are better news than the finding they replace.

A hibernation does not wipe the ephemeral filesystem. A deploy does; a wake does not. Litestream's own log settles it: the replica was at txid.max=0000000000000027 before one sleep, and after the wake it logged a compaction at that same WAL position with no restore in between. We had a comment in the boot script asserting the opposite, written from the same wrong model.

And the bill follows the awake seconds. Over that window Cloud billed the Rust app 1¢ of compute against an arithmetic 1.47¢ at the published per-second price, and billed the Rails sibling 73¢, because its Postgres, Valkey and Reverb are charged whether or not anyone is talking. Continuous operation over the same period would have cost 4.25¢, so scale-to-zero was quietly saving about two thirds of the compute bill on an app nobody was using. That changes the shape of the comparison in the table above: the Rust app's monthly figure is a ceiling it only pays if it is busy all month, and the Rails app's is not.

Then the part Ed suspected. The Action Cable heartbeat is not the cause of the non-symptom — the app slept twelve times with that code running and nothing connected — but it is the one thing that holds the instance awake. Cloud's idle clock counts inbound HTTP requests and nothing else: Litestream replicated and compacted all evening, and the instance still slept five minutes after the last request. An upgraded websocket, though, counts as an in-flight request for its entire life. Measured on a throwaway environment: one browser tab, one room, never touched, held the app awake for 32 minutes, including a sixteen-minute stretch whose request log is completely empty — and it slept 5 minutes and 0 seconds after the socket closed, not after the last request. The same environment with no clients slept 5 minutes 12 seconds after a single curl.

One idle browser tab anywhere in the world holds a Campfire environment at a 100% duty cycle.

That is the correct behaviour for a chat app — a connected client has to be able to receive a message — but it is a cost fact worth knowing before anyone budgets on scale-to-zero.

The shape of this mistake is the same as the one at the top of this write-up. We asked a question the evidence available to us could not answer, got silence back, and read the silence as an answer. Two separate reports in this organisation concluded "scale-to-zero does not work" from exactly that gap, and both of them were wrong in the platform's favour.

Does the trick work for anything else?

The obvious follow-up question about the Go route: is it specific to Rust, or will it carry any compiled binary?

Any binary. A separate experiment, artisan-build/hello_cloud, took the premise down to its floor and found that a go.mod alone is enough — with no Go code at all. Its Rust environment's build command is a shell script that downloads a binary from a GitHub release, checks its SHA-256 and marks it executable; the build step is 4.3 seconds, no compiler is invoked, and the page was serving 44 seconds after the deploy started. Nothing on the platform checks that go build ran, or that ./app is a Go binary. From there it went wide: 36 languages are live on that one application, each on its own environment, including several whose runtime is bundled into a self-extracting ELF to satisfy the one-executable rule.

That is its own write-up, not this one. The point here is only that the launcher we were so pleased with did not need to be a launcher, and did not need to be Go. It has since been written up, at 38 languages: Go as a universal runtime.

What we are taking to Laravel

Ranked by how much it would change what anyone else has to build.

  1. Forward Upgrade and Connection on websocket handshakes. The 520 is fixed and that is a real improvement — but the per-instance nginx blanks Connection and never forwards Upgrade, which is where every HTTP server decides an upgrade is possible. We had to rewrite request bytes in front of our own parser to have a websocket at all. It is two lines and a map, and it unblocks every non-PHP realtime framework on every runtime sharing that template.
  2. proxy_read_timeout 20 will cut any websocket without a sub-20-second heartbeat. Ours held for 90 seconds of silence, but only because Action Cable pings every three seconds. Upgraded connections want their own, much longer, timeout.
  3. Expose the sleep state on the environment, and say in the docs that a wake is a thaw. GET /api/environments/{id} returns status: "running" whether the instance is up or asleep; the only signal anywhere is the replica_count series inside the metrics endpoint, which lags a couple of minutes and is documented nowhere. A sleeping: true, or a state of running|sleeping|stopped, would have saved two separate reports here from concluding scale-to-zero was broken. And one sentence in the docs — your process is suspended and resumed, it does not restart, and the local filesystem survives a wake — removes a whole class of wrong design decisions, besides being a selling point. Two smaller things in the same area: nothing documents that an upgraded connection counts as activity for its whole life, which is the single most important cost fact for a realtime app; and hibernation_timeout lives on the instance resource rather than the environment, which is easy to miss.
  4. X-Forwarded-Proto: http on an HTTPS request. Cf-Visitor says https and X-Forwarded-Port says 443. Unchanged since the Rails run.
  5. Make early-access runtimes discoverable. A per-organisation runtime list in the API or the CLI. Neither the docs, nor the CLI, nor anything we could find in the API says which runtimes an organisation may use — which is how this whole experiment took the long way round.
  6. You cannot create your first application from the CLI if your account has more than one organization. application:create sends you to cloud repo:config --organization=…, and that command refuses to run until an application exists. The only way out is hand-writing .cloud/config.json. Either accept --organization on application:create, or let repo:config --organization run without one.
  7. Cache the cargo registry and target directory. Identical commits recompile from scratch, every deploy, on a small Rust app with no heavy macro trees.
  8. Expose the detected runtime on the environment. Creating an application from a Cargo-workspace default branch now gets a Rust environment directly, which is a real improvement — but the environment still reports php_major_version and node_version for a Rust app, so the only way to know which runtime you got is to read build_command and recognise it. application:create also still ignores a branch field.
  9. Document the start command, or let it be set. A Procfile, or a key in Cargo.toml, would remove a whole class of confusion.
  10. Let the build image install packages, or preinstall a few. No glib, no libvips, no ffmpeg, no codecs, and apt-get cannot run. libglib2.0-dev alone would fix a surprising amount. Today the shared platform's only answer is to fetch prebuilt artifacts from a release of your own.
  11. Gaps between the API and the CLI. Bucket attach exists on PATCH /api/environments/{id} with filesystem_keys but not in the CLI — the one place in an otherwise CLI-complete install where a reader has to drop to curl. The deployment-logs endpoint exists in the API but not the CLI. The web start command has no API field at all, which means an installer cannot set it, and getting it wrong at create time is unrecoverable without a human. bucket:create --json reports "keyIds": [] for a key it did create, and that id is what the attach call needs. And an environment's branch is readable only as a relationship (?include=branch) while PATCH takes a plain {"branch": "…"} — an asymmetry that makes "what branch is this environment on?" needlessly hard to answer.
  12. Document the runtime image. arm64, Debian 12. Knowing that before the first deploy would have saved an hour.
  13. The two wake latencies deserve to be louder than they are. flex-512mb thawed in 0.6 to 0.8 seconds across four measurements on two applications; a legacy flex.c-1vcpu-256mb took 6.69 seconds cold against 0.19 warm. The compute docs do say this. The price list is where people choose a size, and there the only marking on the cheaper sizes is deprecated — so anyone picking one to save a couple of dollars is buying a seven-second first byte with no way to know.
  14. Smaller things. bucket:create demands --allowed-origins even for a private bucket nothing in a browser touches. The bucket's real name is its filesystem id, not its display name. AWS_USE_PATH_STYLE_ENDPOINT is falsy for R2. Adding environment variables through the API requires an undocumented method field. A bucket cannot be deleted until its keys are, with no cascade. The default production environment cannot be deleted or replaced.
  15. A small attachable volume would make the whole class of single-process SQLite applications — which is what ONCE-style software is — viable on Cloud. Litestream covers the database and nothing else.

And credit where it is due: GET /api/deployments/{id}/logs now returns the full build and deploy log. That alone is the difference between this experiment and the Rails one, which lost fifteen deploys to a status string that said deploy.app.crashing and nothing else. The deploy log also prints the start command unexpanded, which is the only reason we ever learned what it is.

What is still open

  • No load test. Every number here comes from a light-load acceptance suite. Nothing we have says what either app does under traffic.
  • Bucket-backed blob serving is unmeasured. Every blob GET is now an HTTPS round trip to the bucket where it used to be a local file read. The ranged-GET path is implemented and streams rather than buffering, but its latency is a guess.
  • Ten behaviours shipped test-verified but not mutation-verified, and they are logged as debt rather than waved through. The one that bothers us most has got worse rather than better: nothing tests which storage service gets selected from the environment, and that selection is now load-bearing for three deployments instead of one, on the branch whose README tells strangers that attaching a bucket is the entire configuration. A silent fall-back to disk would put attachments back on the ephemeral filesystem and every test would still pass.
  • No horizontal scale, by design, and we have not tried to find out what happens to connected clients and in-process jobs when a deploy briefly runs two instances.
  • Whether an open but entirely silent websocket also holds the instance awake is not separable on this app, because Action Cable always pings. Connection and traffic are both present in everything we measured.
  • Nobody has built a fork. The clean-room install cloned rather than forked, so every step but the first is observed and the first one is inferred.
  • A repository should probably not carry a .cloud/config.json at all, and ours still does. The README tells you to overwrite it; that depends on the reader reaching the right paragraph before their first cloud command, which is not a mechanism.

Whether this becomes more than an experiment depends on the conversation with Laravel, same as the Rails one. What we can say now is that the Rust port is the cheaper and much smaller of the two Campfires on Cloud by roughly a factor of four — and cheaper again than that whenever nobody is talking, which we can finally measure. It needs no changes at all to upstream Rust to run on the native runtime. Its main branch is a procedure somebody else can follow rather than a thing only we can deploy. And the one thing standing between it and a clean deploy story is a two-line nginx change that is not ours to make.

The idea behind this experiment

  1. Idea

    Can Campfire's Rust port run on Laravel Cloud?

    Rust just landed in early access on Laravel Cloud, days after 37signals released a Rust port of Campfire that is up to 95 times faster than the Rails app. We just got the Rails version running on Cloud. How much of that fight do we have to have again?

    Read the idea

Support the Lab

The Lab runs on our supporters

Every idea here gets tried for real: deployed, broken, fixed and written up. Right now that is one person doing the work between everything else, and supporters are what buy the time. As support grows, it will also pay freelancers to run experiments of their own, so more of these ideas get tested, sooner.

Our supporters

  • Sputnik Intelligence

    Spain

    We transcribe the podcasts and read the newsletters, and make them searchable. Set up alerts, or hand it all to your AI agent over MCP or API.

    Supporter Visit
    Visit Sputnik Intelligence