A port of ONCE Campfire from Rails to Rust. It was
built to be impossible to tell apart from the Rails app: the same screens pixel for pixel, the same
protocols, and the Rails app's existing SQLite database and storage directory. With that parity
reached, it now diverges from Rails where that makes it faster or better; each divergence is listed
under Known differences. Everything moved to Rust except the frontend: the CSS,
Stimulus controllers, Turbo, Lexxy and the other vendored JavaScript ship as they are, apart from
the few files in crates/assets/overrides/.
The port ships as a single campfire executable (plus libvips and ffmpeg). It replaces Ruby, Puma,
Redis, Resque and Thruster. Against the Rails app it replaces, it serves pages, posts and real-time
delivery 20–95× faster, and holds 10,000 connected clients in a fifth of the memory.
The Rails app lives in reference/ as a git submodule, pinned to the commit being matched. It is
the oracle for everything: no expected output was written by hand. Golden vectors, screenshots and
protocol recordings all come from running the real Rails app.
The port (crates/)
The plan behind it, including why it uses Axum and why pixel parity is tested the way it is, is in
plans/rust-conversion.md.
How parity is proven (parity/)
- A Playwright harness runs the Rails app and the Rust app side by side in pinned containers, on identical seed data generated by the Rails app, with frozen clocks. It compares 225 screen states across Chromium, Firefox and WebKit, four viewports, light and dark mode, and the CSS breakpoints.
- For every state it compares:
- the server HTML
- the live DOM
- the accessibility tree
- every subresource the page loads
- the Action Cable frames
- the screenshot, pixel for pixel with zero tolerance
- Before any Rust code existed, the harness had to show the Rails app matching itself, so that nondeterminism couldn't hide real differences.
Results when the port was finished, before it started to diverge:
The only thing masked then was the random join code on the first-run screen. Since the port began
to diverge, the harness also leaves out the CSRF tags Rails renders, masks the digests of the files
in crates/assets/overrides/, and ignores the session cookie writes and the manifest body that now
differ on purpose; everything else still has to match. The latest lean gate, for the release that
made the repository public, passed 873 of 874 cells in Chromium, Firefox and WebKit; the one left is
the web app manifest, allowlisted as a deliberate difference (parity/allowlist.yml).
These numbers come from benchmarking the v0.1.1
image against the Rails app: production images of both, the same seed data, the same 4 pinned
hardware threads, host networking, and 3 interleaved runs per app. The medians are below; the full
tables with spreads are in
bench/results/v0.1.1-20260928/report.md. The host ran
other light work on other cores during the run; the spread between runs stays within a few percent
for the Rust app.
Every client subscribed in every run, for both apps.
Rails' whole container adds Redis and Thruster to its app processes; the Rust app is one process.
The run before this one benchmarked main at 898653e the same way
(bench/results/scale-20260927). Since then came
cached page parts, the new WebSocket
layer, and opting out of transparent huge pages
(bench/results/thp-20260928):
One Campfire holds 100,000 connected clients in 1.5 GB: every one connects in about 13 s, and
memory stays flat while messages fan out to all of them. On a Raspberry Pi 5's CPU budget
(emulated: four pinned cores capped at 1.2 cores' worth), the app delivered over a million
messages a second to those clients, the load generator's limit, using 0.71 of its 1.2 cores. What
limits a Pi is its gigabit Ethernet: about 51,000 compressed deliveries a second. That's 100,000
chatters in rooms of 100, each posting every five minutes, with a third to spare; a single room of
100,000 can't be busy on one gigabit link. Details and caveats in
bench/results/pi-100k-20260928/report.md.
What changed: Action Cable sockets use their own small WebSocket implementation, which writes each
broadcast's shared bytes to every socket without copying them per connection, and compresses a
broadcast once for all of its subscribers (permessage-deflate, which browsers offer). Connections
run on threads of their own, so page loads and posts don't queue behind a fan-out. The app also
raises its own open-file limit, which in Docker would otherwise stop it at 65,536 clients.
A straight translation was already 3–10× faster than Rails. Profiling (in
plans/perf-attribution.md) then showed where the time went, and each
change since has been measured before and after, keeping the test suite and the parity gate green.
In the order they landed:
Against Rails, the room page went from 4.4× in the preliminary benchmark to 95× in the latest one.
Every response is gzipped at level 6, as Rails' Rack::Deflater does, and after the passes above
that was 60–76% of the CPU on large pages. Most of a room page is cached messages, whose bytes are
the same on every request, so the app stopped compressing them per request, in two steps:
- Spliced gzip. Each cached message is compressed once and kept, and pages splice the stored pieces into the gzip stream. Compressing each message on its own would make a room page 4.4× larger, because consecutive messages share most of their markup, so each piece is compressed against the message before it as a preset dictionary, and reused only when that same message (with the same text between them) comes before it again: the steady state for a room page. The layout around the messages was still compressed live, because every page carried a fresh CSRF token.
- Cached page parts. Without CSRF tokens (see Known differences), a page renders byte for byte the same until what it shows changes, so the layout can be stored too. A page is now split into parts that cover it end to end: its cached messages and the text between them. Each part is compressed once, against the part before it, and kept under the part's identity (the cached fragment, or the SHA-256 of the text) and its predecessor's; a message keeps pieces for the few predecessors it's seen with (its room, a page of older messages, search results). The ETag comes from the parts' digests instead of a SHA-256 over the whole body.
For a 466 KB room page, gzip and the ETag took ~1,200 µs per request at first, ~460 µs after splicing, and 42 µs now; the first request after a page changes pays ~2 ms, once, to compress its new parts. The decoded body is unchanged, and the compressed page is within 1% of compressing it whole.
Each step was measured natively against the commit before it, in its own session, so the columns
come from different runs (the page-parts run on a busy host, which understates it). Details in
bench/results/splice-20260927,
bench/results/header-csrf-20260927 and
bench/results/page-parts-20260927.
It's a drop-in replacement for the Rails image: the same environment variables, ports and storage layout. Point it at an existing Campfire's storage and everyone stays signed in.
With ONCE, on any server with Docker:
once deploy ghcr.io/basecamp/once-campfire-rust --host chat.example.comONCE provides the secrets, TLS, backups and upgrades. The image is published for amd64 and arm64:
:latest and a version tag for each release,
and :main for every change to main (see .github/workflows).
Or with Docker alone:
docker run -d -p 80:80 -p 443:443 \
-e SECRET_KEY_BASE=... -e VAPID_PUBLIC_KEY=... -e VAPID_PRIVATE_KEY=... \
-e TLS_DOMAIN=chat.example.com \
-v campfire:/rails/storage \
ghcr.io/basecamp/once-campfire-rust- TLS: with TLS_DOMAINset, the app gets and renews its own Let's Encrypt certificate. It keeps certificates where Thruster did, so an existing install keeps its certificate.
- Plain HTTP: set DISABLE_SSLinstead, for running behind another proxy.
- Web Push: VAPID_PUBLIC_KEYandVAPID_PRIVATE_KEYare a P-256 key pair in URL-safe Base64 (as the Rails image takes them). They're checked at boot; without a valid pair, push notifications are off and the log says why.VAPID_SUBJECTis the contact push services see (amailto:orhttps:URL); it defaults tohttps://and yourTLS_DOMAIN.
- The app port: as with Puma behind Thruster, the app also answers on TARGET_PORT(3000) without the front server's cache and compression, but only on loopback. SetTARGET_BIND(e.g.0.0.0.0) to open it further; it trustsX-Forwarded-*from whoever reaches it.
- Storage: everything lives under /rails/storage: the SQLite database, uploaded files and backups.
- Media: the image builds libvips 8.16.1 (thumbnails and other variants) and ffmpeg 7.1.5
(video posters, and ffprobe for video and audio metadata) from the Debian trixie source packages
the Rails image installs, with the same flags and libraries, so thumbnails and posters are byte
for byte the ones Rails makes. Only what Campfire can reach goes in: libvips loads PNG, GIF,
JPEG, TIFF, WebP, AVIF and HEIC/HEIF (with EXIF orientation and ICC profiles) and saves PNG,
JPEG, GIF and WebP; ffmpeg keeps every built-in demuxer and decoder plus dav1d for AV1, the
filters that pick and orient a poster frame, and only the MJPEG encoder and image2muxer that write it; other encoders and muxers, hardware, network and external codec libraries are left out. That took the image from 640 MB to 169 MB unpacked, and from 246 MB to 67 MB to download (see theDockerfile).
- Many clients: every connected browser is a socket, and the app raises its open-file limit to the hard limit at startup (Docker's default soft limit would stop it at 65,536). Past that, it's memory (~15 KB per client) and bandwidth; see 100,000 clients.
- ONCE hooks: /hooks/pre-backuprunscampfire backup, which uses SQLite's online backup API.
- Other options: see crates/campfire/src/config.rs.
To build the image yourself: docker build -t campfire-rust . (the reference/ submodule must be
checked out). For development:
cargo test --workspace --exclude html5ever # all crates
cargo run -p campfire -- server # needs SECRET_KEY_BASE or SECRET_KEY_BASE_DUMMY=1parity/bin/reference build && parity/bin/candidate build # Rails and Rust images
parity/bin/seed build # seed data, generated by the Rails app
parity/bin/candidate compare # lean parity gate, Rust vs Rails
parity/bin/compare --matrix full ... # full matrix, for release checks
reference-tools/http_shape/sweep.py <rails-url> <rust-url> # response header shape
bench/run # benchmark both apps
bench/results/pi-100k-20260928/run100k.sh BIN LABEL # 100,000 cable clients (PI=1: a Pi 5's budget)parity/SCREENS.md documents the screen inventory, masks and the flake policy.
AGENTS.md describes the repository layout and working rules, and
CONTRIBUTING.md how to propose changes. Report security issues as
SECURITY.md describes.
Deliberate:
- Compressed WebSocket frames. The app accepts the permessage-deflatecompression browsers offer (without context takeover, so each broadcast is compressed once for all of its subscribers); Rails' Action Cable doesn't negotiate it. The frames decode to the same messages.
- No CSRF tokens. Forgery protection checks the Sec-Fetch-Siteheader browsers send, as Rails main'sprotect_from_forgery using: :header_onlydoes, instead of per-request tokens. Writes are accepted fromsame-originandsame-siterequests;cross-siteones, and HTTPS requests without the header, get a 422. TheOrigincheck still applies. On plain HTTP, where browsers don't send the header, a missing one is accepted, and theSameSite=Laxsession cookie and theOrigincheck protect writes. Pages have nocsrf-tokenmeta tag orauthenticity_tokenfields, so they render byte for byte the same until what they show changes: ETags now match on revalidation, and a page's markup can be cached. Browsers from before 2023 that don't send the header (e.g. Safari before 16.4) can't submit forms over HTTPS. Tabs opened before an upgrade keep working: their tokens are ignored, and the header does the job.
- Redis and Resque are gone. Jobs run in-process and are best-effort: a crash loses queued
webhooks and pushes, as a Redis restart would under Rails. Each kind of job (pushes, webhooks,
purges, ...) has its own queue and JOB_CONCURRENCYworkers, so a slow bot's webhooks can't hold up push notifications.
- Push subscriptions are kept through our own failures. Rails destroys a push subscription on any OpenSSL error, which includes a bad VAPID key and any TLS failure (an empty CA store, a skewed clock), so a configuration mistake deleted everyone's subscriptions on the next message. The VAPID keys are now checked once at boot (Web Push is off, with a log line, when they're missing or don't form a key pair), and a subscription is destroyed only when the push service answers 410 or 404 (RFC 8030; Rails keeps it on a 404) or its own key isn't a valid P-256 point.
- Long messages still get push notifications. Rails puts the whole message in the notification, and one over about 4 KB fails to encrypt (a Web Push message holds 4096 bytes), so nobody is notified. The notification's body is now cut short with an ellipsis at 3 KB, and its title at 256 bytes.
- The VAPID subject is configurable. Rails identifies every install to push services as
mailto:support@37signals.com; this usesVAPID_SUBJECT, orhttps://and the firstTLS_DOMAIN, or the project's URL.
- Cookies are only sent when they change. Rails rewrites the session cookie, re-signs the
session_tokencookie and re-setslast_roomon nearly every response. The session cookie is now written only when the session changed, and deleted once it's empty (it only holds the flash and a return-to URL);session_tokenis re-signed when the session's hourly activity refresh runs, which keeps its 20-year expiry rolling;last_roomis set when it changes. An authenticated request whose session doesn't need that refresh also no longer passes through the database writer.
- ETags aren't a digest of the body on pages made of cached messages (room, messages and search pages): they're a SHA-256 over the page's parts. Identical pages still get identical ETags, and any change gets a new one.
- One more index. On boot the app adds index_messages_on_room_id_and_created_atto the Rails schema if it's missing (a one-time 49 ms for 236k messages). Rails' schema pages a room's messages throughindex_messages_on_room_idalone, which sorts the room's whole history for every page. The index is additive, so the database still works with the Rails image.
- Leaner libvips and ffmpeg. The image builds both from the same Debian sources as the Rails
image, leaving out what Campfire can't reach (see Running it). Thumbnails, video
posters and metadata come out byte for byte the same for every image and video format either
image handles. libvips loses only loaders that Vips.block_untrustedalready blocks (ImageMagick, SVG, PDF, JPEG XL, JPEG 2000, OpenEXR, FITS, Matlab, OpenSlide). ffmpeg loses the decoders and demuxers that come from external libraries with no built-in equivalent: tracker modules (libopenmpt), game-console music (libgme), JPEG XL and SVG frames, codec2 speech, teletext subtitles, and DASH/IMF manifests. Tracker modules and game-console music attached to a message are now stored without duration or bit rate, which Campfire never shows.
- Limits where Rails had none, or raised. Request bodies other than file uploads are capped at 16 MiB (a 413), and so are Active Storage direct uploads, which Campfire's editor doesn't use: asking for a larger one is a 413. A QR code for more than a QR code can hold is a 422, not a 500. Page numbers are capped at a billion. A WebSocket connection holds up to 64 subscriptions with identifiers of up to 4 KiB, and a client that doesn't read what it's sent for 30 seconds is disconnected. Deactivating or banning a user closes their open connections once the change commits.
- Link unfurling is bounded in time. Rails gives each connect and read of an unfurl 60
seconds, across up to 10 redirects and the image check. Now an unfurl gets 10 seconds in all and
5 per connect or read, and a page that takes longer unfurls nothing. At most 16 unfurls run at
once, and only a metatag's first 256 attributes are read.
- Bot webhooks are bounded. A delivery gets 60 seconds in all, on top of Rails' 7 per connect or read; one that runs out answers "Failed to respond within 60 seconds", as a 7-second timeout answers with its own. A reply larger than 100 MB (after decompression) fails the delivery and posts nothing; Rails read replies of any size into memory.
- Push deliveries are bounded in time. A push service gets 10 seconds per connect or read and 30
in all, where the web-push gem leaves Net::HTTP's 60 seconds per step; a slow service would otherwise hold one of the few push workers for minutes.
- The front server is stricter than Thruster. The app's own listener on TARGET_PORTbinds loopback only (Puma bound every interface) and has the front's timeouts andMAX_REQUEST_BODY(see Running it). The response cache counts its keys towardCACHE_SIZE, skips URIs longer than 2 KB, keys on the raw path (Thruster decoded it, so/a%2Fband/a/bshared an entry), and lets range requests through to the app instead of answering them with a whole cached body.
- Media is processed off the database writer. Rails saves a blob's row and then uploads its file after commit; here the upload is copied into storage first, straight from the request's tempfile, and deleted again if the save fails. Variants, video posters and analysis run on background threads (at most four at a time), and only their rows are written in a transaction, so a large image or video doesn't hold up other writes. A variant or poster is saved already analyzed, where Rails analyzes it in a job after commit; the rows end up the same. Two requests for the same missing variant may both transform it: the first to save wins and the other's file is deleted. ffmpeg is stopped after 60 seconds of drawing a poster and ffprobe after 30 seconds of reading a file, which Rails doesn't limit.
- Passwords are hashed and checked outside the database. bcrypt (about 250 ms) runs before the write that saves a password, and a sign-in looks the user up and then verifies the password after releasing the database connection. An unknown email address still costs one bcrypt, as in Rails.
- Searches are for words. Rails passes a search's words to SQLite's full-text MATCHas they are, soNOT,AND,ORorNEARin the wrong place is a 500. Each word is now matched as itself.
- /rooms/directs/:idredirects to the room instead of answering 500.
- Edge's install instructions render. With an EdgeHTML user agent (Edge/), Rails answers profile and room pages with a 500 because the partial names an image that isn't there (install-edge.svg); the Rust app ships it.
- New-ping suggestions appear. The user picker for a new ping asks for JSON; in Rails it asks for anything, gets HTML, and never shows a suggestion.
- Autolinking can't break out of an attribute. rails_autolink finds URLs and email addresses
with regular expressions over the sanitized HTML, which Nokogiri serializes with <and>left raw in attribute values. A URL after a>in, say, atitlewas taken for text and linked, and the inserted<a href="...">closed the attribute, turning the rest of its value into live markup (a stored XSS; it affects the Rails app). The port escapes<and>in attribute values before autolinking, so URLs inside attributes stay as they were. The same DOM otherwise.
- Cached markup doesn't carry the request's host. A message's "Copy link" button held an
absolute URL built from the Host header, inside a fragment cached for everyone, so one request
with a forged Host changed the link everyone copied. The button now carries the message's path
(data-copy-to-clipboard-url-value), and the copy-to-clipboard controller (an override) makes it absolute against the page. The bot API's cached JSON, whose URLs must be absolute, is cached per base URL instead.
- Rich text drops nameattributes. Rails' default sanitizer allowlist keeps them, which lets a message clobber the page's DOM globals (<img name="body">shadowsdocument.body). Nothing Campfire's composer writes has one.
- Rich text keeps only highlight colors in style. Where Rails runsstylethrough Loofah's CSS scrubber, the sanitizer keeps onlycolorandbackground-colorwith a plain color value (a keyword, hex,rgb()/hsl(), or a custom property like Lexxy'svar(--highlight-1)), which is all Lexxy writes. It shows in the HTML body the bot API and webhooks send; message pages dropstylealtogether, as they did.
- The web app manifest is valid JSON. Rails HTML-escapes the account name and URLs into
webmanifest.json, so a name with\or"broke the manifest and the small logo's URL read?size=small&v=.... They're JSON strings now.
- Content attachments nest at most 8 deep. An <action-text-attachment>carrying HTML in itscontentrenders that content, attachments included; each level parses and sanitizes everything below it again, so a 336 KB body of nested ones took 10 seconds to render. Deeper levels now render empty. Campfire's composer doesn't nest them at all.
- A mention of a deleted user shows ☒. Rails can't find a "missing" partial for users, so the mention raised and blanked the whole message, and editing the message raised too. The rest of the message now shows with ☒ in the mention's place, and the editor leaves the mention out.
- Not ported: the duplicate session_tokencookie Rails' Active Storage streaming sends; and legacy AES-CBC encrypted cookies, since Campfire started on GCM.
Not fully covered:
- HTTP-01 ACME validation is only unit-tested. TLS-ALPN-01 was tested end to end against a local ACME server.
- Rich text is checked against Rails on a 647-case corpus, 400 of them fuzzed, which matches exactly apart from the deliberate differences above. Active Storage attachments embedded in a message body, which Campfire's composer can't create, render as ☒.
The port was built in about a day by coordinated Claude Code agents, each owning one crate or
harness component. They followed the plan in plans/rust-conversion.md, which Codex also reviewed.
plans/overnight-report.md logs the unattended overnight run: the
parity gate going green, the Thruster replacement, the benchmarks, and each optimization with its
before and after numbers. The optimizations and divergences since then were made the same way, one
pull request each, with their measurements in bench/results/.
MIT, like Campfire. See MIT-LICENSE.