2026-04-16 | PreviewProof Team
Preview-Ready Phoenix LiveView: Working Configuration for Ephemeral Environments
Phoenix LiveView’s preview story is interesting in a way the rest of this Ring 5 series isn’t. A LiveView session is not stateless HTTP. It’s a long-lived WebSocket connection between the browser and a server-side process — the LiveView LiveProcess — that holds state across user interactions. Most preview tooling assumes stateless HTTP and falls down on this in subtle ways: dropped connections on cold-start, broken reconnects when the preview hostname changes, and clustering questions that don’t have an obvious right answer.
This is a niche post but a high-credibility one. Phoenix teams know what they need; the goal is to be specific enough to be useful.
Reference stack: Phoenix 1.7 with LiveView, Ecto, Postgres 16, optional Phoenix Channels alongside LiveView. ExMachina for factory-based seed data.
docker-compose.yml: local development
services: app: build: context: . target: dev command: mix phx.server ports: - "4000:4000" environment: MIX_ENV: dev DATABASE_URL: ecto://postgres:postgres@db/app_dev SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev-secret-please-change} PHX_HOST: localhost depends_on: db: { condition: service_healthy }
db: image: postgres:16 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: app_dev healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 5docker-compose.yml: preview-ready
Once the stack moves to ephemeral previews, the same docker-compose.yml shifts to a release-built image, env-var-injected PHX_HOST, and the runtime config that LiveView needs:
services: app: image: ghcr.io/myorg/myapp:${GIT_SHA} command: ./bin/preview-entrypoint.sh environment: MIX_ENV: prod RELEASE_NAME: app DATABASE_URL: ecto://postgres:postgres@db/app_preview SECRET_KEY_BASE: ${SECRET_KEY_BASE} PHX_HOST: ${PREVIEW_HOST} PHX_SERVER: "true" PORT: "4000" DNS_CLUSTER_QUERY: "" # disable clustering for previews LIVE_VIEW_SIGNING_SALT: ${LIVE_VIEW_SIGNING_SALT}PHX_HOST is the most important variable. Phoenix uses it for URL generation, including the WebSocket endpoint that LiveView connects to. If PHX_HOST doesn’t match the preview’s actual public hostname, the LiveView client fetches the page successfully and then fails to upgrade the WebSocket — the symptom is a working initial render that immediately disconnects. Easy to spot, easy to debug if you know to look.
DNS_CLUSTER_QUERY is the modern Phoenix 1.7 way to set up libcluster for BEAM clustering. For preview environments, you almost always want it disabled. More on clustering below.
The preview entrypoint
Phoenix releases run differently from mix phx.server. The release binary is what production uses, and what previews should use too — same compiled artifact, same runtime config path.
#!/usr/bin/env bashset -e
echo "==> Migrating"./bin/app eval "App.Release.migrate()"
echo "==> Seeding"./bin/app eval "App.Release.seed()"
echo "==> Starting"exec ./bin/app startThe App.Release module is the convention for running Ecto migrations from a release without mix available. A working version:
defmodule App.Release do @app :app
def migrate do load_app() for repo <- repos() do {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) end end
def seed do load_app() Application.ensure_all_started(@app) Code.eval_file("priv/repo/seeds.exs") end
defp repos do Application.fetch_env!(@app, :ecto_repos) end
defp load_app do Application.load(@app) endendThe seed.exs file uses ExMachina factories with a deterministic random seed:
:rand.seed(:exsplus, {1, 2, 3})
alias App.{Repo, Accounts, Projects}alias App.Factory
admin = u -> u end
unless Repo.exists?(Projects.Project) do Factory.insert_list(5, :project, owner: admin)endThe unless ... exists? pattern keeps the seed idempotent — re-running on a non-empty database does nothing. See seeding Postgres for preview environments for the broader pattern.
LiveView WebSocket origin and CSRF
LiveView’s WebSocket endpoint enforces origin checks against your endpoint’s :check_origin setting. In preview environments, the dynamic hostname needs to be permitted.
import Config
if config_env() == :prod do config :app, AppWeb.Endpoint, url: [host: System.fetch_env!("PHX_HOST"), port: 443, scheme: "https"], check_origin: [ "https://#{System.fetch_env!("PHX_HOST")}" ], secret_key_base: System.fetch_env!("SECRET_KEY_BASE"), live_view: [signing_salt: System.fetch_env!("LIVE_VIEW_SIGNING_SALT")]endThe hostname-driven check_origin is the cleanest pattern. A wildcard like :check_origin ["//*.previews.example.com"] works too if you want to set it once at the platform level rather than per-PR.
LIVE_VIEW_SIGNING_SALT deserves a note. LiveView signs payloads passed between client and server with this salt. It must be stable for the lifetime of a preview but should differ per preview to prevent cross-environment session reuse. Generate it once when the preview is created and inject it as an env var.
BEAM clustering: usually skip it
Most Phoenix apps don’t need clustering in preview environments. A LiveView session lives on one node; a single-node preview handles the entire session lifecycle on its own. Disabling clustering (the empty DNS_CLUSTER_QUERY above) keeps the boot path simple and removes a category of preview-specific failures.
The exception is apps that use Phoenix.PubSub across nodes for cross-process communication where the test scenarios require multi-node behavior. Those teams know who they are. For everyone else: single node, clustering disabled, ship it.
Reconnection behavior in ephemeral envs
LiveView’s client automatically reconnects when the WebSocket drops. In preview environments, this matters more than in production because:
- Preview containers can be restarted (deployments, scale-down events, infrastructure churn).
- Preview hostnames are stable for the life of a preview but the underlying machines may change.
- Cold-start on a paused preview can take long enough that the LiveView reconnect timeout fires.
The phoenix_liveview JS client retries on a backoff. The default is reasonable. Two settings worth tuning for previews:
params: { _csrf_token: ... }— the CSRF token must be present in the initial WebSocket upgrade. Make sure your layout includes the meta tag.- The
Socketconstructor’sreconnectAfterMscallback can be tweaked if your preview platform has known cold-start times.
In practice, the defaults are fine. Just know that “first interaction takes a beat” is normal cold-start behavior, not a bug.
Channels alongside LiveView
If you use Phoenix Channels for non-LiveView WebSocket flows (chat, real-time data feeds), they share the same socket endpoint and the same check_origin configuration. Nothing additional to configure for previews — if LiveView works, Channels work.
Env template
MIX_ENV=prodSECRET_KEY_BASE= # generated per preview, 64+ bytesLIVE_VIEW_SIGNING_SALT= # generated per preview, 8+ bytesDATABASE_URL= # injected by platformPHX_HOST= # injected: pr-123.previews.example.comPHX_SERVER=trueDNS_CLUSTER_QUERY= # empty for single-node previewsPhoenix is one of the more mechanically reliable preview-target stacks once the WebSocket origin and PHX_HOST are right. The cold-start cost is real but predictable; the LiveView session model maps cleanly onto per-PR previews because each preview is a fresh BEAM instance with no carryover state. The Compose configuration above is portable across preview tools and self-hosted setups.
If you’d rather not configure the per-PR routing yourself, PreviewProof handles PHX_HOST injection and WebSocket-friendly TLS termination for Compose-shaped Phoenix stacks, so LiveView previews work without bespoke proxy configuration. The verification layer is the more interesting piece for teams whose review process involves non-engineers or formal sign-off — the LiveView config above is what it runs on top of.