basecamp/once-campfire-elixir

Campfire ported to Elixir, with Rails compatibility checks and Rust benchmark comparisons

Elixir

2

2 commits

updated Oct 4, 2026

See the code

README

Campfire in Elixir

An Elixir implementation of ONCE Campfire. It keeps the existing SQLite database, storage layout, signed/encrypted cookies and Action Cable protocol, so existing installs can retain their data and sessions.

The application runs on Elixir 1.19.5 / OTP 28 with Bandit and Plug. Redis and a native Resque-compatible worker handle jobs and broadcasts; the same Thruster binary as Rails handles TLS, HTTP/2 and proxy caching. libvips and FFmpeg process media. The Rails frontend is preserved, including Turbo and the composer.

Running it

Build the production image locally. The build uses the pinned Rails reference for assets and Thruster; check out the submodule first:

git submodule update --init

docker build --build-arg GIT_REVISION="$(git -C reference rev-parse HEAD)" \
  -t campfire-reference:app reference
docker build -f Dockerfile.dev -t campfire-elixir:toolchain .
bin/mix local.hex --force
bin/mix local.rebar --force
bin/mix deps.get
bin/export-assets
docker build -t campfire-elixir:release .

Then run Docker:

docker run -d --name campfire -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 \
  campfire-elixir:release
  • TLS_DOMAIN enables Thruster's automatic Let's Encrypt certificates. For local plain HTTP, omit it and set DISABLE_SSL=true.
  • /rails/storage holds the database, uploads, backups and certificates. Existing installations must retain their storage and secrets.
  • Web Push requires a valid P-256 VAPID key pair in URL-safe Base64. Use your own production secrets; parity/reference.env contains public test keys.
  • Redis starts inside the container by default. REDIS_URL selects an external Redis. The native job worker starts automatically; bin/jobs can also run it against the same database, Redis and storage environment.
  • The app listener binds loopback behind Thruster. Forwarded URL headers are trusted from the local proxy. The current Dockerfile packages the amd64 Thruster binary.
  • The image includes ONCE backup/restore hooks. Fresh installation, backup/restore and Rails → Elixir → Rails rollback have passed on disposable volumes. A hosted image and automated release publishing are not configured in this repository.

Performance

Production images, the same populated seed and four pinned hardware threads per app on an AMD Ryzen AI MAX+ 395. These are medians of two runs per version on October 4, 2026. Elixir comes from the earlier alternating Elixir/older-Rust runs; Ruby, Go and the optimized Rust version were each measured separately afterward with the same harness and settings, using each public server with gzip. The full report preserves ranges, image IDs, response sizes and the original measurement environments.

The measured code revisions are Elixir 3498c18, Rails 90b3300, Go 504428a, and Rust 1ea6d6f, using the same optimized production image as the Rust README. The report retains the image ID; its build revision identifies the measured Rust code. The local checkout recorded by the harness is used for the seed and is a different revision. Go's published HTTP comparison uses its direct application listener with identity encoding; this table measures its production public server with gzip.

HTTP throughput (16 concurrent clients)

MeasurementRuby (Rails)ElixirGoRust
Room page216 req/s722 req/s3,860 req/s36,260 req/s
Messages page384 req/s1,053 req/s5,573 req/s40,872 req/s
Sidebar503 req/s1,275 req/s19,753 req/s34,672 req/s
Search380 req/s1,156 req/s7,053 req/s33,299 req/s
Post a message267 req/s801 req/s4,767 req/s6,896 req/s
Avatar94,703 req/s96,970 req/s200,856 req/s364,915 req/s
Static CSS129,639 req/s125,904 req/s289,554 req/s383,609 req/s
/up4,054 req/s8,493 req/s154,476 req/s230,480 req/s

Latency and real time

MeasurementRuby (Rails)ElixirGoRust
Room page p99, 64 clients474.0 ms108.2 ms60.5 ms3.1 ms
Post a message p99, 64 clients368.3 ms83.1 ms59.7 ms14.5 ms
Upload a 505 KB JPEG until its thumbnail is served57.8 ms92.6 ms28.4 ms28.3 ms
Complete broadcasts/s, 1,000 clients in one room12.460.2239.1545.6
Deliveries/s, 1,000 clients in one room12,41260,219239,119545,654
Paced post → all 1,000 clients, p50134.6 ms18.3 ms9.4 ms6.2 ms
Paced post → all 1,000 clients, p99188.4 ms21.7 ms15.9 ms8.5 ms
Connect and subscribe 1,000 clients1.50 s0.40 s0.17 s0.12 s

Every client subscribed and received every paced and saturated broadcast. All HTTP workloads returned validated successful responses with zero errors. Cable uses one authenticated user with many connections; these loopback measurements exclude TLS and NIC costs.

Startup, memory and image size

MeasurementRuby (Rails)ElixirGoRust
Cold start until /up answers2,601 ms530 ms145 ms177 ms
Idle container memory, including page cache298 MiB152 MiB16 MiB32 MiB
Peak anonymous container memory1,419 MiB564 MiB349 MiB268 MiB
App memory, 1,000 idle clients (PSS)678 MiB394 MiB169 MiB126 MiB
App memory, 1,000 clients under load (PSS)991 MiB423 MiB218 MiB125 MiB
Whole container, 1,000 clients under load (PSS)1,381 MiB600 MiB218 MiB125 MiB
Image size, unpacked1,232 MiB2,490 MiB230 MiB225 MiB

Ruby includes Puma, Redis and Thruster. Elixir includes BEAM, Redis, native helpers and Thruster. Go and Rust each use one integrated app/server process. Container memory varies with page cache; PSS apportions shared pages. MiB means 1,048,576 bytes.

The earlier Elixir tuning comparison records an 8.07× CSS improvement, 3.42× improvement in 1,000-client fanout and 1.65× improvement in message posting over the initial Elixir build, using an older Rust image. Both tuned Elixir runs ended with empty job queues and no failed jobs. Rust remains substantially faster on dynamic HTTP and fanout.

Development

The project follows rails-to-rust: an immutable reference, source inventory, runtime oracles, captured compatibility vectors, generated records/routes, HTTP and mutation comparisons, an evidence ledger and reusable migration tooling in tools/rails-to-elixir/. The Rails reference is pinned to 90b3300.

Use the Docker toolchain above, then run:

bin/mix format --check-formatted
bin/mix compile --warnings-as-errors
bin/mix test --warnings-as-errors
bin/parity-services start
bin/rails-to-elixir parity --force
bin/rails-to-elixir mutation-diff --force
bin/rails-to-elixir doctor
bin/parity-services stop

The complete verified run passes 65 gates and 1,896 tests, including actual Chromium flows, all-table/FTS/storage mutation snapshots, injected transaction failures, media operations, cross-runtime Cable delivery and session revocation, worker claims/failures/drain, webhook replies and encrypted HTTPS push delivery, TLS/HTTP2, fresh schema/setup and production rollback. See the evidence ledger, verification record and conversion state.

The fixture services use isolated data, Redis and ports 47070/47071/47079. Frozen Rails comparisons additionally require the campfire-reference:latest image from the Rust parity harness, built from the same pinned reference. Live gates reset their fixture data and must run sequentially. Unit tests disable the HTTP server and external job adapter.

For the full gate run, start a disposable Chromium instance on port 47080 with a separate profile, then start the fixture services:

mkdir -p var
chromium --headless=new --no-sandbox --disable-dev-shm-usage --no-first-run \
  --disable-extensions --disable-background-networking \
  --user-data-dir="$PWD/var/browser-profile-isolated" \
  --remote-debugging-port=47080 \
  --host-resolver-rules='MAP campfire.test 127.0.0.1' about:blank \
  > var/browser.log 2>&1 &
bin/parity-services start
bin/verify-parity

The browser gate creates and disposes separate contexts for Rails and Elixir. Benchmarks run separately from verification; see bench/README.md for the workload, validation and reproduction commands.

Known differences

The compatibility checks retain explicit rich-text comparison rules:

  • Attribute values escape < and > to prevent stored XSS, following the Rust port's protection. Autolinks are never inserted inside attributes.
  • Malformed rich-text failures are compared by category because Ruby and Elixir exception classes and diagnostic wording differ.
  • When Rails' rescue logger fails on invalid UTF-8, native presentation returns the intended empty string. Failed plain-text extraction renders the Rails failed-message partial.

The corpus covers 1,058 stored-content cases. The native parser uses unmodified Gumbo sources from Rails' Nokogiri 1.19.4. Expected oracle output is retained; raw differences and the comparison rules are documented in plans/richtext-comparison.md.

Elixir retains Redis and Resque-compatible jobs, while Rust uses integrated queues and a different frontend/server implementation. Their actual process models, response sizes and compression ratios are recorded with the benchmarks. No production cutover has been performed.

License

MIT. See MIT-LICENSE.

basecamp/once-campfire-elixir

Campfire ported to Elixir, with Rails compatibility checks and Rust benchmark comparisons

Elixir

2

2 commits

updated Oct 4, 2026

See the code

README

Campfire in Elixir

An Elixir implementation of ONCE Campfire. It keeps the existing SQLite database, storage layout, signed/encrypted cookies and Action Cable protocol, so existing installs can retain their data and sessions.

The application runs on Elixir 1.19.5 / OTP 28 with Bandit and Plug. Redis and a native Resque-compatible worker handle jobs and broadcasts; the same Thruster binary as Rails handles TLS, HTTP/2 and proxy caching. libvips and FFmpeg process media. The Rails frontend is preserved, including Turbo and the composer.

Running it

Build the production image locally. The build uses the pinned Rails reference for assets and Thruster; check out the submodule first:

git submodule update --init

docker build --build-arg GIT_REVISION="$(git -C reference rev-parse HEAD)" \
  -t campfire-reference:app reference
docker build -f Dockerfile.dev -t campfire-elixir:toolchain .
bin/mix local.hex --force
bin/mix local.rebar --force
bin/mix deps.get
bin/export-assets
docker build -t campfire-elixir:release .

Then run Docker:

docker run -d --name campfire -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 \
  campfire-elixir:release
  • TLS_DOMAIN enables Thruster's automatic Let's Encrypt certificates. For local plain HTTP, omit it and set DISABLE_SSL=true.
  • /rails/storage holds the database, uploads, backups and certificates. Existing installations must retain their storage and secrets.
  • Web Push requires a valid P-256 VAPID key pair in URL-safe Base64. Use your own production secrets; parity/reference.env contains public test keys.
  • Redis starts inside the container by default. REDIS_URL selects an external Redis. The native job worker starts automatically; bin/jobs can also run it against the same database, Redis and storage environment.
  • The app listener binds loopback behind Thruster. Forwarded URL headers are trusted from the local proxy. The current Dockerfile packages the amd64 Thruster binary.
  • The image includes ONCE backup/restore hooks. Fresh installation, backup/restore and Rails → Elixir → Rails rollback have passed on disposable volumes. A hosted image and automated release publishing are not configured in this repository.

Performance

Production images, the same populated seed and four pinned hardware threads per app on an AMD Ryzen AI MAX+ 395. These are medians of two runs per version on October 4, 2026. Elixir comes from the earlier alternating Elixir/older-Rust runs; Ruby, Go and the optimized Rust version were each measured separately afterward with the same harness and settings, using each public server with gzip. The full report preserves ranges, image IDs, response sizes and the original measurement environments.

The measured code revisions are Elixir 3498c18, Rails 90b3300, Go 504428a, and Rust 1ea6d6f, using the same optimized production image as the Rust README. The report retains the image ID; its build revision identifies the measured Rust code. The local checkout recorded by the harness is used for the seed and is a different revision. Go's published HTTP comparison uses its direct application listener with identity encoding; this table measures its production public server with gzip.

HTTP throughput (16 concurrent clients)

MeasurementRuby (Rails)ElixirGoRust
Room page216 req/s722 req/s3,860 req/s36,260 req/s
Messages page384 req/s1,053 req/s5,573 req/s40,872 req/s
Sidebar503 req/s1,275 req/s19,753 req/s34,672 req/s
Search380 req/s1,156 req/s7,053 req/s33,299 req/s
Post a message267 req/s801 req/s4,767 req/s6,896 req/s
Avatar94,703 req/s96,970 req/s200,856 req/s364,915 req/s
Static CSS129,639 req/s125,904 req/s289,554 req/s383,609 req/s
/up4,054 req/s8,493 req/s154,476 req/s230,480 req/s

Latency and real time

MeasurementRuby (Rails)ElixirGoRust
Room page p99, 64 clients474.0 ms108.2 ms60.5 ms3.1 ms
Post a message p99, 64 clients368.3 ms83.1 ms59.7 ms14.5 ms
Upload a 505 KB JPEG until its thumbnail is served57.8 ms92.6 ms28.4 ms28.3 ms
Complete broadcasts/s, 1,000 clients in one room12.460.2239.1545.6
Deliveries/s, 1,000 clients in one room12,41260,219239,119545,654
Paced post → all 1,000 clients, p50134.6 ms18.3 ms9.4 ms6.2 ms
Paced post → all 1,000 clients, p99188.4 ms21.7 ms15.9 ms8.5 ms
Connect and subscribe 1,000 clients1.50 s0.40 s0.17 s0.12 s

Every client subscribed and received every paced and saturated broadcast. All HTTP workloads returned validated successful responses with zero errors. Cable uses one authenticated user with many connections; these loopback measurements exclude TLS and NIC costs.

Startup, memory and image size

MeasurementRuby (Rails)ElixirGoRust
Cold start until /up answers2,601 ms530 ms145 ms177 ms
Idle container memory, including page cache298 MiB152 MiB16 MiB32 MiB
Peak anonymous container memory1,419 MiB564 MiB349 MiB268 MiB
App memory, 1,000 idle clients (PSS)678 MiB394 MiB169 MiB126 MiB
App memory, 1,000 clients under load (PSS)991 MiB423 MiB218 MiB125 MiB
Whole container, 1,000 clients under load (PSS)1,381 MiB600 MiB218 MiB125 MiB
Image size, unpacked1,232 MiB2,490 MiB230 MiB225 MiB

Ruby includes Puma, Redis and Thruster. Elixir includes BEAM, Redis, native helpers and Thruster. Go and Rust each use one integrated app/server process. Container memory varies with page cache; PSS apportions shared pages. MiB means 1,048,576 bytes.

The earlier Elixir tuning comparison records an 8.07× CSS improvement, 3.42× improvement in 1,000-client fanout and 1.65× improvement in message posting over the initial Elixir build, using an older Rust image. Both tuned Elixir runs ended with empty job queues and no failed jobs. Rust remains substantially faster on dynamic HTTP and fanout.

Development

The project follows rails-to-rust: an immutable reference, source inventory, runtime oracles, captured compatibility vectors, generated records/routes, HTTP and mutation comparisons, an evidence ledger and reusable migration tooling in tools/rails-to-elixir/. The Rails reference is pinned to 90b3300.

Use the Docker toolchain above, then run:

bin/mix format --check-formatted
bin/mix compile --warnings-as-errors
bin/mix test --warnings-as-errors
bin/parity-services start
bin/rails-to-elixir parity --force
bin/rails-to-elixir mutation-diff --force
bin/rails-to-elixir doctor
bin/parity-services stop

The complete verified run passes 65 gates and 1,896 tests, including actual Chromium flows, all-table/FTS/storage mutation snapshots, injected transaction failures, media operations, cross-runtime Cable delivery and session revocation, worker claims/failures/drain, webhook replies and encrypted HTTPS push delivery, TLS/HTTP2, fresh schema/setup and production rollback. See the evidence ledger, verification record and conversion state.

The fixture services use isolated data, Redis and ports 47070/47071/47079. Frozen Rails comparisons additionally require the campfire-reference:latest image from the Rust parity harness, built from the same pinned reference. Live gates reset their fixture data and must run sequentially. Unit tests disable the HTTP server and external job adapter.

For the full gate run, start a disposable Chromium instance on port 47080 with a separate profile, then start the fixture services:

mkdir -p var
chromium --headless=new --no-sandbox --disable-dev-shm-usage --no-first-run \
  --disable-extensions --disable-background-networking \
  --user-data-dir="$PWD/var/browser-profile-isolated" \
  --remote-debugging-port=47080 \
  --host-resolver-rules='MAP campfire.test 127.0.0.1' about:blank \
  > var/browser.log 2>&1 &
bin/parity-services start
bin/verify-parity

The browser gate creates and disposes separate contexts for Rails and Elixir. Benchmarks run separately from verification; see bench/README.md for the workload, validation and reproduction commands.

Known differences

The compatibility checks retain explicit rich-text comparison rules:

  • Attribute values escape < and > to prevent stored XSS, following the Rust port's protection. Autolinks are never inserted inside attributes.
  • Malformed rich-text failures are compared by category because Ruby and Elixir exception classes and diagnostic wording differ.
  • When Rails' rescue logger fails on invalid UTF-8, native presentation returns the intended empty string. Failed plain-text extraction renders the Rails failed-message partial.

The corpus covers 1,058 stored-content cases. The native parser uses unmodified Gumbo sources from Rails' Nokogiri 1.19.4. Expected oracle output is retained; raw differences and the comparison rules are documented in plans/richtext-comparison.md.

Elixir retains Redis and Resque-compatible jobs, while Rust uses integrated queues and a different frontend/server implementation. Their actual process models, response sizes and compression ratios are recorded with the benchmarks. No production cutover has been performed.

License

MIT. See MIT-LICENSE.

Languages

Elixir

42.2%

Python

26.0%

HTML

22.2%

Rust

3.1%

Ruby

2.8%

Shell

1.6%

C

1.4%