Rails Integration

bext sits in front of a Ruby on Rails application as the edge layer. Rails keeps the entire framework experience — controllers, views, ActiveRecord, generators, tests — and bext adds the production concerns every Rails team has to build or bolt on: TLS termination, HTTP/2 and HTTP/3, a web application firewall, edge cache, Prometheus + OpenTelemetry, and DDoS absorption.

This is not an adapter in the Astro/SvelteKit sense. Rails is a full server-side framework that ships its own HTTP layer via Puma; bext integrates by reverse-proxying to Puma rather than by wrapping Rails's render pipeline. The starter below is a scaffold — a working example of the shape, not a runnable Rails app. You bring Rails; bext fronts it.

When to use this. If your team already has a Rails application and you want to put bext in front for the edge concerns (TLS, cache, WAF, metrics), this is the integration for you. If you want to build a new non-Rails SSR app on bext, use PRISM or one of the JS framework adapters instead.

Architecture

    ┌─────────┐          ┌───────────────────────────┐          ┌─────────────┐
    │         │  HTTPS   │            bext           │   HTTP   │    Puma     │
    │ client  │ ───────► │  TLS • WAF • cache • obs  │ ───────► │   :3000     │
    │         │  :443    │           :8080           │ 127.0.0.1│   + Rails   │
    └─────────┘          └───────────────────────────┘          └─────────────┘

Request flow:

  1. Client opens a TLS connection to bext on :443 (or :8080 in dev). 2. bext terminates TLS, runs the WAF, checks the cache, records metrics, then forwards the request to Puma over HTTP/1.1 on 127.0.0.1:3000 via the upstream pool. 3. Rails processes the request. The response streams back through bext, which applies compression and logging, and on to the client over HTTP/2 or HTTP/3.

Puma never sees the public internet. Rails never ships TLS code. bext never parses ActionDispatch.

bext.config.toml

The starter's config, stripped to the essentials:

[server]
port = 8080

# Upstream: Puma running Rails
[upstreams.rails]
strategy = "round_robin"
keepalive = 32

[[upstreams.rails.servers]]
url = "http://127.0.0.1:3000"

[upstreams.rails.health]
enabled = true
path = "/up"                 # Rails 7.1+ ships this endpoint
interval_ms = 10000

[upstreams.rails.retry]
count = 1
retry_on = ["connect_error", "502", "503"]

[[route_rules]]
pattern = "/*"
proxy = "rails"

[waf]
enabled = true

The important parts:

  • [upstreams.rails] declares the pool. For a single Puma process, one [[upstreams.rails.servers]] entry is enough. For multiple Puma instances (zero-downtime deploys, blue/green), add more entries and bext round-robins between them. - [upstreams.rails.health] runs a background probe against Rails's built-in /up endpoint. A dead Puma is evicted from the pool within a few seconds. - [upstreams.rails.retry] retries a limited set of idempotent failures (connection errors, 502/503) once, with a 100ms delay. Safe defaults — override per-project as needed. - [[route_rules]] sends every path to the rails upstream. Add more specific rules above this one to carve out static asset paths (e.g. /assets/* served directly from bext) or bext-native routes that bypass Rails entirely.

Running Puma under systemd

bext expects Puma to be up before it starts forwarding. In production, run Puma as a systemd unit so the OS restarts it on crash:

# /etc/systemd/system/rails-app.service
[Unit]
Description=Puma for Rails app
After=network.target

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/srv/app/current
Environment=RAILS_ENV=production
ExecStart=/usr/bin/env bundle exec puma -C config/puma.rb -p 3000
Restart=on-failure
RestartSec=2
StandardOutput=append:/var/log/rails-app.log
StandardError=append:/var/log/rails-app.log

[Install]
WantedBy=multi-user.target

Then:

sudo systemctl enable --now rails-app
sudo systemctl enable --now bext

Restart order doesn't matter in practice — bext's upstream health probe will find Puma as soon as it comes up.

What bext adds

Concern Who handles it
TLS / ACME bext
HTTP/2, HTTP/3 (QUIC) bext
WAF (bots, rate limit) bext
Edge cache (ISR / SWR) bext
Compression (brotli) bext
Prometheus metrics bext
OpenTelemetry traces bext
Static asset serving Either (see below)
Business logic Rails
ActiveRecord / DB Rails
Background jobs Rails (Sidekiq, Solid Queue, etc.)
Asset pipeline Rails / Propshaft / Sprockets

Static assets

By default the starter forwards everything to Puma, so Rails's public/ directory is served by Rails. For better edge performance, serve public/ directly from bext:

[[route_rules]]
pattern = "/assets/*"
render = "static"
# bext will look for files under `public/assets/` relative to app_dir.

[[route_rules]]
pattern = "/packs/*"     # or /packs-test/* if using webpacker
render = "static"

[[route_rules]]
pattern = "/*"
proxy = "rails"

With these rules, bext serves hashed asset bundles with immutable cache headers; Puma only sees dynamic traffic.

Cache invalidation

bext's edge cache is off by default for proxied routes — Rails responses already set appropriate Cache-Control headers and bext respects them. To cache a Rails response at the edge, set a Cache-Control: public, max-age=... header in the controller and add a matching [[route_rules]] entry with cache = true.

To invalidate cache from inside Rails, call bext's tag-based purge API:

# app/services/bext_cache.rb
require "net/http"
class BextCache
  def self.invalidate(tag)
    uri = URI("http://127.0.0.1:8080/__bext/cache/purge")
    Net::HTTP.post_form(uri, "tag" => tag)
  end
end

Now BextCache.invalidate("products") wipes every cache entry tagged products across the bext edge.

Detection

bext's infra import scanner recognises a Rails project from:

- a Gemfile that declares gem "rails", and

- a config.ru file

When both are present, bext infra import suggests the Rails integration pattern and offers to scaffold from @bext/starter-rails.

The catalog entry lives in crates/bext-import/catalog/frameworks.yaml:

- match:
    any_of_files:
      - "Gemfile"
      - "config.ru"
    gemfile:
      any_of_gems:
        - "rails"
  extract:
    framework: rails
    starter: "@bext/starter-rails"
  confidence: high

The starter

bext new my-rails-app --template @bext/starter-rails
cd my-rails-app
bundle install
bin/rails server -p 3000     # one terminal
bext serve                    # another

Visit http://localhost:8080.

The starter is a scaffold, not a working Rails app. It ships the minimum shape bext needs: a Gemfile, a config.ru, a config/routes.rb, a tiny home#index controller and ERB view, and the bext.config.toml plumbing wired up. Use rails new to generate a real Rails project and drop the starter's bext.config.toml into it — that is the canonical workflow.

How this compares to nginx + Puma

If you currently run nginx in front of Puma, the migration is mechanical:

nginx.conf                          bext.config.toml
─────────                           ────────────────
upstream rails { server … }   ───►  [upstreams.rails.servers] url = …
proxy_pass http://rails;      ───►  [[route_rules]] proxy = "rails"
listen 443 ssl;               ───►  [tls] (auto ACME)
ssl_certificate …;            ───►  [tls.acme] domains = […]
limit_req_zone …;             ───►  [waf.rate_limit]

bext's nginx-compat mode can even read your existing nginx.conf and run it directly — no migration needed for the first pass.

Limitations

  • No request mutation from bext plugins (yet). bext plugins can run at request / response time, but the reverse-proxy path doesn't yet expose the full plugin surface that SSR routes see. Plugin integration on proxied routes lands with the Router capability in E11. - No WebSocket multiplexing. bext forwards WebSockets to the upstream today but doesn't yet share a connection pool for them. High-fan-out Action Cable apps should look at the follow-up bext-realtime work. - No embedded Ruby runtime. Unlike the PHP integration (which embeds libphp via a custom SAPI), bext does not embed a Ruby VM. Rails runs under its own Puma process. This is the right trade-off for Rails: the Rails team ships Puma, bundler, and the full toolchain; bext fronting them via HTTP preserves every upstream DX affordance.

Related

- @bext/starter-rails — the scaffold

- Reverse proxy docs — the upstream pool primitive

- Auto TLS — ACME-based certificates

- WAF & Security — edge protections

- From nginx migration — upstream-block translation