All posts
preview-environmentstutorialdocker

Phoenix LiveView Preview Environment: A Live URL Per PR

PreviewDrop Team·November 2, 2026·7 min read

Phoenix is the framework that made "no JavaScript build step" a selling point again. Then you open a pull request and the reviewer gets a screenshot.

The reason is specific to LiveView. A LiveView page isn't a static render — it's a websocket connection that mounts a process, holds state, and pushes diffs back over the wire. Every part of that chain has to survive the trip from your laptop to a hostname someone else can reach. Most preview setups break at the socket, and the failure looks like a page that loads and then does nothing.

Here's what a Phoenix preview actually needs, and how to get a live URL on every branch without writing a Dockerfile or a CI pipeline.

Why LiveView is harder to preview than it looks

A Django or Rails preview is mostly a question of getting a process to listen on the right port. Phoenix adds a second transport. The initial HTTP request renders the static markup, then app.js opens a websocket to the same origin, and from that point every interaction is a server round-trip.

That means a preview needs three things to agree: the hostname the browser used, the hostname the socket connects to, and the origin the server will accept. Get any one wrong and you get a page that renders, then silently stops responding to clicks — the worst kind of bug, because it looks like your code.

The good news is that all three are configuration, not code. Phoenix already handles the hard part.

The four settings that decide whether a preview works

1. PHX_HOST and the endpoint's url config

Phoenix builds absolute URLs — for sockets, for ~p route helpers, for redirects — from config :my_app, MyAppWeb.Endpoint, url: [host: ...]. If that host is hard-coded to example.com, the socket will try to connect to production no matter where the page is served from.

Read the host from the environment instead:

# config/runtime.exs
import Config

if config_env() == :prod do
  host =
    System.get_env("PHX_HOST") ||
      raise "PHX_HOST is missing"

  config :my_app, MyAppWeb.Endpoint,
    url: [host: host, port: 443, scheme: "https"],
    http: [port: String.to_integer(System.get_env("PORT") || "4000")],
    secret_key_base: System.fetch_env!("SECRET_KEY_BASE")
end

runtime.exs is the right file for this because it runs at boot, not at compile time. A value baked in at compile time can't differ between your preview and production.

2. Bind to the injected PORT on all interfaces

PreviewDrop sets PORT, HOST, and HOSTNAME on every preview container and routes traffic to that port. That's the full list — there is no injected PREVIEW_URL, so don't write code that expects one.

The http: [port: ...] line above covers the port. The interface is the quieter failure: some projects set http: [ip: {127, 0, 0, 1}] for local development. Inside a container, localhost is the container itself, so external traffic never arrives. Leave ip unset, or bind to {0, 0, 0, 0}.

3. check_origin — the socket's allowlist

This is the one that produces the "page loads, buttons do nothing" symptom. Phoenix's socket refuses connections whose Origin header isn't in check_origin. The default in production accepts only the host from your endpoint config, which is exactly why a preview on a different hostname gets rejected.

Preview hostnames follow prv-<id>.previews.previewdrop.dev, so allow that shape explicitly rather than disabling the check:

# config/runtime.exs
preview_host = ~r/^prv-[a-z0-9-]+\.previews\.previewdrop\.dev$/

config :my_app, MyAppWeb.Endpoint,
  check_origin: [
    "https://example.com",
    preview_host
  ]

check_origin accepts a list mixing exact strings and regexes, so this keeps production locked down while letting previews through. Do not set check_origin: false — it's the one setting in this list that has a real security cost, and you don't need it.

4. A SECRET_KEY_BASE for the preview

Phoenix won't boot in production mode without secret_key_base, and it shouldn't — that key signs cookies and tokens. Generate one value for previews and store it in the project's environment variables alongside your database URL and API keys. It doesn't need to match production; it needs to be present and stable across deploys, or every push will invalidate sessions.

mix phx.gen.secret

Run that once, paste the output into the project's environment variables, and every preview gets it.

Assets, migrations, and the build

Three remaining details, all short.

Assets. Phoenix 1.7+ ships with esbuild and tailwind as Mix aliases. If your deploy step runs mix assets.deploy, the compiled bundles land in priv/static and are served by the endpoint. If you skip it, the page renders unstyled — a preview that "works" but looks broken enough that reviewers don't trust it. Make sure the build command includes the asset step.

Migrations. A preview runs your app's container; it does not come with a database attached. Point previews at a shared development database or a throwaway instance you control, and decide deliberately whether to run mix ecto.migrate on boot. Running it is convenient and safe when the database is disposable; it's a footgun against a shared database other people are using.

The build itself. PreviewDrop detects Elixir from mix.exs and builds the project for you — no Dockerfile, no YAML pipeline. You can override the install, build, and start commands in Project Settings if your umbrella layout or release config needs something specific.

Connect the repo

With those four settings in place, setup is short:

  1. Connect the repository. PreviewDrop reads mix.exs and detects Elixir.
  2. Paste SECRET_KEY_BASE, PHX_HOST, and your database URL into the project's environment variables.
  3. Override the build command if you need mix assets.deploy or a custom release step.
  4. Open a pull request.

The first build compiles your dependencies and warms the cache; later builds reuse it. When it finishes, a comment lands on the pull request with the URL. Push again and the same URL updates in place. Close or merge the PR and the preview is torn down. If a build can't start the container, the deployment is marked failed with the reason shown in the dashboard rather than sitting in queued and counting against your concurrent-preview limit.

What changes in review

LiveView's whole pitch is that you can build interactive UI without a client-side framework. That pitch only lands when someone else can click it. A reviewer reading a diff sees a handle_event clause and has to imagine the interaction; a reviewer with a URL clicks the button, watches the socket push the diff, and knows.

For a Phoenix team the cost is genuinely low. The build is fast, the release is a single artifact, and the only real work is the four config values above — which you probably want in runtime.exs anyway, because hard-coded hostnames are a production problem too.

Start with one branch

Pick the pull request that's been waiting longest, connect the repo, and push a commit to that branch. If the page loads and the buttons respond, the socket is wired correctly and you're done.

Read the quickstart docs for the full walkthrough, or sign up and put a live URL on your next pull request. The Free plan covers two concurrent previews across three projects with a four-hour lifetime — enough to prove the loop on a real branch. Paid plans start at $19/mo for the workspace, not per seat.

Want previews on your own pull requests? Start free — 2 concurrent previews, no credit card.