Experiment
Campfire on Laravel Cloud: what it actually took
· Ed Grosvenor
Short version: it works. Campfire is running on Laravel Cloud's early-access Ruby runtime, on managed Postgres, a managed Valkey cache, an S3-compatible bucket and a Resque worker. Two people can chat in a room in real time, attachments land in the bucket, search works, and everything survives a redeploy. Campfire's full test suite passes on Postgres: 461 runs, no failures.
The fork is public at artisan-build/campfire-experiment. It's a throwaway, built to find out what the minimum change looks like, not to run in production. Most of the porting was done by coding agents working from written briefs, with us checking their claims against the live app.
The guesses, scored
| We guessed | What happened |
|---|---|
| SQLite would have to go | Right. Postgres, plus a rewrite of the FTS5 search onto Postgres full-text search |
| S3 would have a surprise in it | Right, twice: a checksum clash on upload, and image variants that assume a disk |
| Managed Redis might object to something | Right. The Valkey ACL refuses a command Action Cable sends on connect |
| Websockets were the real question | Right. They don't get through, so we had to go around |
| Early access would have rough edges | Right, and the biggest one was one we never guessed |
Eighteen deploys, and the first seventeen failed
The Ruby build worked first time. The deploys did not. Every one failed with the same status, deploy.app.crashing, even though the logs showed Puma booting cleanly and answering health checks from inside the container.
We ruled things out one variable per deploy: port mismatches, the health-check path, the worker process, a leftover instance, hibernation. None of it mattered. The answer was in the dashboard's deploy log and nowhere else. The API and CLI only ever report the status string. Laravel Cloud's network is IPv6-only. Puma was listening on 0.0.0.0, the IPv4 wildcard, so nothing on Cloud's network could reach it.
The fix has two parts. The bind address becomes configurable, with the upstream default left alone:
# config/puma.rb
# Laravel Cloud's network is IPv6-only, so production sets
# PUMA_BIND_HOST=[::]; the default stays IPv4 to match upstream.
PORT=ENV.fetch("PORT", 3000)
bind "tcp://#{ENV.fetch("PUMA_BIND_HOST", "0.0.0.0")}:#{PORT}"
And the start script has to launch Puma directly, because rails server passes its own host and port and quietly overrides the config file:
# bin/start-app
exec bundle exec puma -C config/puma.rb
Deploy eighteen went green. The lesson we took away: when Cloud says crashing, read the dashboard deploy log before you try anything else.
The smaller fixes
Don't disable SSL. Campfire has a DISABLE_SSL setting for running behind a proxy that terminates TLS, which sounds like exactly Cloud's setup. Setting it made Rails think every request was plain HTTP while the browser was sending an HTTPS origin, so every form, starting with the first-run wizard, failed its CSRF check. Cloud terminates TLS and forwards the right headers, so leave it unset.
Valkey won't let you name your connection. Cloud's managed Valkey locks down what the app's user can do, and CLIENT SETNAME is not allowed. Action Cable names its Redis connection, so every message 500'd. The fix is a new initializer, not an edit to upstream code:
# config/initializers/action_cable_redis.rb
ActionCable::SubscriptionAdapter::Redis.redis_connector = ->(config) do
::Redis.new(config.except(:adapter, :channel_prefix, :id))
end
One checksum at a time. Recent versions of the AWS SDK add a CRC32 checksum to every upload, on top of the MD5 that Active Storage already sends. Cloud's S3-compatible storage rejects a request with both. Two lines in config/storage.yml fix it:
cloud:
service: S3
# ...
request_checksum_calculation: when_required
response_checksum_validation: when_required
Thumbnails assumed a disk. Uploads worked, but image variants (avatars and previews) 500'd, because the code asks for a local file path that an S3 service doesn't have. There is already an open pull request upstream that fixes it. We cherry-picked it rather than writing our own. Campfire is deliberately a single-server app, so we don't expect S3 fixes to be a priority upstream.
Realtime, the hard way
Our biggest question got a clear answer: Action Cable websockets do not get through Cloud's edge. The upgrade request to /cable comes back as a 520, because Cloud's web proxy doesn't forward the Upgrade header to the app. Action Cable itself was healthy; the connection just never reached it.
Cloud does have managed websockets. They are Reverb, which speaks the Pusher protocol. So instead of asking Action Cable to get through the proxy, we taught it to publish through Reverb:
- A new Action Cable subscription adapter publishes every broadcast to Reverb's HTTP API. None of Campfire's existing broadcasts had to change.
- In the browser, a small shim swaps Action Cable's consumer for
pusher-js, so the existing channel code keeps working. - A channel auth endpoint reuses Campfire's own room permissions, so someone outside a room gets a 403 for its channel.
The cost was 11 lines changed across 4 upstream files, plus 17 new files. New files almost never conflict with upstream changes, so this is a much cheaper diff than it sounds.
Reverb on a Rails app had rough edges of its own. Cloud won't attach a Reverb application to a Rails environment yet, so the connection details are set by hand. A new Reverb app allows no origins until you say otherwise. And Reverb caps a message at 10,000 bytes, while a Campfire message broadcast is about 11,400, so we compress the payload before sending it.
Where it stands
Campfire is live on the Reverb branch, with realtime, attachments, search and a Resque worker all working. We're taking everything above to the Laravel Cloud team as early-access feedback. The two changes that would make most of the workarounds unnecessary are forwarding websocket upgrades to Rails apps, and letting a Rails environment attach Reverb the way a Laravel one can.
The bigger decision is still open. Keeping the diff small was the goal for the experiment. Some of what Cloud needs, starting with the S3 fixes, is unlikely to land upstream, which points toward a proper fork maintained for Cloud: it would watch upstream for patches and apply them deliberately, rather than trying to stay a thin layer on top. We will decide that after the conversation with Laravel, and a one-click Campfire install through Scalpels comes back on the table once we have.