2026-04-16 | PreviewProof Team

Preview-Ready Phoenix LiveView: Working Configuration for Ephemeral Environments

phoenixliveviewelixirpreview environmentswebsockets

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

docker-compose.yml
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: 5

docker-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:

docker-compose.yml
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.

bin/preview-entrypoint.sh
#!/usr/bin/env bash
set -e
echo "==> Migrating"
./bin/app eval "App.Release.migrate()"
echo "==> Seeding"
./bin/app eval "App.Release.seed()"
echo "==> Starting"
exec ./bin/app start

The App.Release module is the convention for running Ecto migrations from a release without mix available. A working version:

lib/app/release.ex
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)
end
end

The seed.exs file uses ExMachina factories with a deterministic random seed:

priv/repo/seeds.exs
:rand.seed(:exsplus, {1, 2, 3})
alias App.{Repo, Accounts, Projects}
alias App.Factory
admin =
case Accounts.get_user_by_email("[email protected]") do
nil -> Factory.insert(:user, email: "[email protected]", role: :admin)
u -> u
end
unless Repo.exists?(Projects.Project) do
Factory.insert_list(5, :project, owner: admin)
end

The 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.

config/runtime.exs
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")]
end

The 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 Socket constructor’s reconnectAfterMs callback 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

.env.preview.example
MIX_ENV=prod
SECRET_KEY_BASE= # generated per preview, 64+ bytes
LIVE_VIEW_SIGNING_SALT= # generated per preview, 8+ bytes
DATABASE_URL= # injected by platform
PHX_HOST= # injected: pr-123.previews.example.com
PHX_SERVER=true
DNS_CLUSTER_QUERY= # empty for single-node previews

Phoenix 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.