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:
- Client opens a TLS connection to bext on
:443(or:8080in dev). 2. bext terminates TLS, runs the WAF, checks the cache, records metrics, then forwards the request to Puma over HTTP/1.1 on127.0.0.1:3000via 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/upendpoint. 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 therailsupstream. 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
Routercapability 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-upbext-realtimework. - 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