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). - 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:3000via the upstream pool. - 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"
require "json"
class BextCache
def self.invalidate(tag)
uri = URI("http://127.0.0.1:8080/__bext/cache/purge")
Net::HTTP.post(uri, { keys: [tag] }.to_json, "Content-Type" => "application/json")
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
Gemfilethat declaresgem "rails", and - a
config.rufile
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.
If your team is migrating from an existing nginx + Puma setup, see From nginx — bext's nginx-compat mode can read your existing nginx.conf directly, making the first pass a zero-rewrite migration.
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-up
bext-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
Links#
- Ruby on Rails — official Rails documentation