Phoenix Integration
bext sits in front of an Elixir Phoenix application as the edge layer. Phoenix keeps the entire framework experience — controllers, LiveView, Channels, Ecto, PubSub, OTP — and bext adds the production concerns every Phoenix 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. Phoenix is a full server-side framework that ships its own HTTP layer via Cowboy (or Bandit, if you prefer); bext integrates by reverse-proxying to Cowboy rather than by wrapping Phoenix's render pipeline. The starter below is a scaffold — a working example of the shape, not a runnable Phoenix app. You bring Phoenix; bext fronts it.
When to use this. If your team already has a Phoenix application (with or without LiveView) 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-BEAM SSR app on bext, use PRISM or one of the JS framework adapters instead.
Architecture
┌─────────┐ ┌───────────────────────────┐ ┌─────────────┐
│ │ HTTPS │ bext │ HTTP │ Cowboy │
│ client │ ───────► │ TLS • WAF • cache • obs │ ───────► │ :4000 │
│ │ :443 │ :8080 │ 127.0.0.1│ + Phoenix │
└─────────┘ └───────────────────────────┘ └─────────────┘
Request flow:
- Client speaks HTTPS (HTTP/2 or HTTP/3 over QUIC) to bext on
:443. 2. bext terminates TLS, runs the WAF, checks the edge cache, and emits telemetry. 3. On cache miss, bext opens a keepalive HTTP/1.1 connection to Cowboy on127.0.0.1:4000and forwards the request. 4. Phoenix runs the Plug pipeline (Router → Controller → View). 5. The response flows back through bext — which writes cache entries, compresses (Brotli / zstd / gzip), and emits metrics — to the client.
Cowboy never listens on a public interface. Bind it to
127.0.0.1:4000 (as in config/dev.exs and config/prod.exs)
and let bext be the only public listener.
LiveView safety
Phoenix LiveView establishes a WebSocket on first mount (at
/live/websocket by default). For bext to forward LiveView
traffic correctly, the /live/* path must:
- Skip the edge cache — LiveView pushes per-client diffs, and cached responses would desync every connected client. - Carry the WebSocket upgrade headers through — bext needs to treat the route as upgrade-capable so the HTTP/1.1 → WS handshake passes untouched.
The starter's bext.config.toml ships a /live/* route rule that
does both. Keep it above the catch-all /* rule:
[[route_rules]]
pattern = "/live/*"
proxy = "phoenix"
cache = false
websocket = true
[[route_rules]]
pattern = "/*"
proxy = "phoenix"
If you deploy multiple Phoenix nodes behind bext, enable sticky
sessions on the LiveView route (see the Upstreams section of the
reverse proxy docs) — a LiveView socket
is pinned to the node where mount/3 ran, and a round-robin
second connection on the same page will fail to reconnect.
Cowboy vs Bandit
Phoenix 1.7 supports two HTTP servers:
| Server | Default since | Why pick it |
|---|---|---|
Cowboy (plug_cowboy) |
historical | Mature, battle-tested, the default in older starters. Works fine behind bext. |
Bandit (bandit) |
1.7 new projects | Pure-Elixir, faster on small requests, simpler supervision tree. Works fine behind bext. |
Both listen on HTTP/1.1 to bext on 127.0.0.1:4000. bext is the
HTTP/2 and HTTP/3 terminator regardless of which Phoenix server
you pick. The starter uses Cowboy for broad compatibility — swap
to Bandit by changing {:plug_cowboy, "~> 2.6"} to
{:bandit, "~> 1.2"} in mix.exs and setting
adapter: Bandit.PhoenixAdapter in config/config.exs.
Static files
Phoenix serves its compiled static assets (hashed names under
priv/static/assets/) via Plug.Static in the Endpoint pipeline.
The starter leaves that untouched and proxies / through to
Phoenix — so asset requests pass through bext, get cached at the
edge on the first request, and are served from the edge on
subsequent requests.
To serve priv/static/ directly from bext without hitting
Phoenix, add a rule above the catch-all:
[[route_rules]]
pattern = "/assets/*"
render = "static"
static_root = "priv/static"
headers = { "Cache-Control" = "public, max-age=31536000, immutable" }
This is optional — the edge cache does most of the same work for free, and going direct only helps if you have asset-heavy pages with low cache-hit ratios.
systemd
For production, run Phoenix behind a systemd unit so bext can
restart it cleanly. Prefer a Mix release (mix release) over
mix phx.server — releases start faster, ship without Mix or
Hex, and are the standard BEAM production pattern.
[Unit]
Description=Phoenix app (Cowboy) (%i)
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/srv/app
Environment=MIX_ENV=prod
Environment=PHX_HOST=example.com
Environment=PORT=4000
# Release-based (preferred):
ExecStart=/srv/app/_build/prod/rel/hello/bin/hello start
# Or, Mix-based fallback:
# ExecStart=/usr/bin/env mix phx.server
ExecStop=/srv/app/_build/prod/rel/hello/bin/hello stop
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
A release also gives you hot-upgrade support, which bext's edge cache + graceful connection draining combine with nicely — the old Phoenix node keeps serving in-flight requests while the new one warms up.
Trusting bext's forwarded headers
bext sets X-Forwarded-For, X-Forwarded-Proto, and
X-Forwarded-Host on every upstream request. Phoenix does not
trust these headers by default — configure Plug.RewriteOn or the
remote_ip library so conn.remote_ip, conn.scheme, and
conn.host reflect the real client instead of the loopback
connection from bext.
Add to config/prod.exs:
config :hello, HelloWeb.Endpoint,
http: [
ip: {127, 0, 0, 1},
port: 4000,
compress: false
]
# Plug.RewriteOn picks up X-Forwarded-* headers from bext.
# :x_forwarded_for is the most important — otherwise every
# request looks like it came from 127.0.0.1.
config :plug, :validate_header_keys_during_test, true
And wire Plug.RewriteOn into your Endpoint above Plug.Static:
plug Plug.RewriteOn, [:x_forwarded_host, :x_forwarded_port, :x_forwarded_proto]
Do not enable force_ssl in Phoenix when bext is the TLS
terminator — Phoenix will see an HTTP connection from bext and
redirect-loop. bext handles HTTPS redirection at the edge.
Cache invalidation
bext's edge cache is tag-addressable. Invalidate from Phoenix via the HTTP control API the cache plugin exposes:
defmodule HelloWeb.CacheInvalidation do
def purge(tag) do
Req.post!("http://127.0.0.1:8080/__bext/cache/purge",
json: %{tag: tag}
)
end
end
# Anywhere in your app:
HelloWeb.CacheInvalidation.purge("post:#{post.id}")
Pair this with Cache-Tag response headers from Phoenix to mark
which edge entries belong to which tags.
Detection
bext's catalog detects Phoenix projects automatically during
bext infra import and bext site autoconfigure via the rule at
crates/bext-import/catalog/frameworks.yaml:
- match:
any_of_files: ["mix.exs"]
mix:
any_of_packages: ["phoenix"]
extract:
framework: phoenix
starter: "@bext/starter-phoenix"
The match is a substring scan of mix.exs for {:phoenix, — fast
and dependency-free, no Elixir toolchain required. If a project
matches, bext infra import offers to generate the reverse-proxy
bext.config.toml from the starter.
Known gaps
- Channels beyond LiveView. Phoenix Channels on non-
/live/*paths need their own route rule withwebsocket = true. bext treats WebSocket routes as opt-in. - Long-poll fallback. If you disable WebSocket transport inphoenix_live_viewand fall back to long-poll, disable the edge cache on those routes (cache = false) — cached long-poll responses will break the connection. - Mix releases and cache invalidation. Release upgrades restart the BEAM VM; the bext edge cache survives the restart, so purge relevant tags as part of your deploy script or queries will return stale data from the old release. - bext does not run EPMD or Erlang distribution. For clustered Phoenix nodes, keep distribution on a private interface and use bext only as the HTTP edge.
See also
- Reverse proxy — upstream pools and health probes