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:

  1. 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 on 127.0.0.1:4000 and 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 with websocket = true. bext treats WebSocket routes as opt-in. - Long-poll fallback. If you disable WebSocket transport in phoenix_live_view and 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

- WebSocket and SSE support

- Cache plugin — tag invalidation

- bext auto TLS