Guides · Deployment

Phoenix hosting: deploy an Elixir release

What a Phoenix 1.8 release needs to start and accept LiveView connections, how to run migrations, and the Dockerfile Staxa builds.

By Jonathan Pitter ·

The short answer

Deploy Phoenix as a release (mix release), not with mix phx.server. A release is a self-contained directory with your compiled app and the Erlang runtime, and it needs four environment variables to start properly:

VariableWhy
PHX_SERVER=truemix phx.new's runtime.exs starts the HTTP endpoint only when it's set. Without it, the release boots and listens on nothing.
PHX_HOSTYour domain. Phoenix builds URLs from it, and LiveView only accepts WebSocket connections whose origin matches it.
SECRET_KEY_BASESigns and encrypts cookies and sessions. The release refuses to start without it.
DATABASE_URLThe Ecto connection, read by runtime.exs.

PORT sets the listening port, defaulting to 4000. We tested everything below on Phoenix 1.8 with Elixir 1.17 and 1.19 and PostgreSQL 16: the release built and running as a non-root user, a LiveView WebSocket accepted for the right origin, and migrations run from the release.

PHX_SERVER and PHX_HOST

config/runtime.exs in a new project contains:

if System.get_env("PHX_SERVER") do
  config :demo, DemoWeb.Endpoint, server: true
end

and, for production:

host = System.get_env("PHX_HOST") || "example.com"

config :demo, DemoWeb.Endpoint,
  url: [host: host, port: 443, scheme: "https"],

The url host is what LiveView's origin check compares the browser's Origin header against. With PHX_HOST unset, it's example.com, and every LiveView page renders once and then fails to connect, reloading in a loop. We measured the WebSocket upgrade with the origin https://web-acme.staxa.app: 101 Switching Protocols with PHX_HOST=web-acme.staxa.app, 403 with PHX_HOST=0.0.0.0. Set it to the domain users visit, and if you serve the app on more than one, list them in check_origin in config/prod.exs.

url: [port: 443, scheme: "https"] is right behind a proxy that terminates TLS. The generated prod.exs also sets force_ssl: [rewrite_on: [:x_forwarded_proto]], which trusts the proxy's X-Forwarded-Proto header, so proxied HTTPS requests aren't redirected.

SECRET_KEY_BASE

mix phx.gen.secret

Set the output as SECRET_KEY_BASE. Keep it stable between deploys; changing it signs everyone out.

The database

runtime.exs reads DATABASE_URL and raises at boot if it's missing. A URL with query options works as it is; we connected with postgres://user:pass@host:5432/db?sslmode=disable. Set POOL_SIZE to change the connection pool from its default of 10.

Migrations from a release

A release doesn't include Mix, so mix ecto.migrate isn't available. Generate the release helpers once:

mix phx.gen.release

That adds lib/demo/release.ex and two scripts, rel/overlays/bin/migrate and rel/overlays/bin/server, which end up in the release's bin/. Run bin/migrate before the new version takes traffic; in our test it applied the migration from inside the running release's image. With one instance, the simplest place is an entrypoint that migrates and then starts the server:

#!/bin/sh
set -e
bin/migrate
exec bin/server

mix phx.gen.release --docker also writes a Debian-based Dockerfile that does the build for you, if you want to start from that.

Deploying Phoenix on Staxa

If your repository has a Dockerfile, Staxa builds it; the Phoenix and PostgreSQL demo has one with an entrypoint that migrates and seeds. If not, Staxa detects Phoenix from mix.exs, reads your app's name from it (app: :demo), and generates this Dockerfile (port 4000, Elixir 1.19):

FROM elixir:1.19-alpine AS build
WORKDIR /app
RUN apk add --no-cache build-base git npm
ENV MIX_ENV=prod
RUN mix local.hex --force && mix local.rebar --force
COPY mix.exs mix.lock ./
RUN mix deps.get --only prod && mix deps.compile
COPY . .
RUN mix compile
RUN if [ -f assets/package.json ]; then npm ci --prefix assets; fi
RUN if [ -d assets ]; then mix assets.deploy; fi
RUN mix release

# The same image as the build: a release links against its OpenSSL and musl,
# and the elixir image moves to newer Alpine releases on its own schedule.
FROM elixir:1.19-alpine
WORKDIR /app
RUN adduser -D -u 1001 app
COPY --from=build --chown=app /app/_build/prod/rel/demo .
USER app
ENV LANG=C.UTF-8
# mix phx.new starts the endpoint only when PHX_SERVER is set.
ENV PHX_SERVER=true
ENV PORT=4000
EXPOSE 4000
CMD ["bin/demo", "start"]

What this means for your app:

  • PHX_SERVER and PORT are handled. Set SECRET_KEY_BASE and PHX_HOST in the service's environment. PHX_HOST is <service>-<tenant>.staxa.app, or your custom domain.
  • DATABASE_URL is set for you when the tenant has a database, with DB_HOST, DB_PORT, DB_NAME, DB_USER and DB_PASSWORD.
  • Assets are built with the esbuild and Tailwind versions in config/config.exs, and npm ci runs only if assets/package.json exists (new Phoenix 1.7 and 1.8 apps have none).
  • It doesn't run migrations. After mix phx.gen.release, the release contains bin/migrate; use your own Dockerfile with the entrypoint above to run it on each deploy.
  • The runtime stage is the Elixir image. A release's crypto library is linked against the build image's OpenSSL, and a mismatch stops it loading. We hit exactly that copying a release built on the current elixir:1.17-alpine into alpine:3.20: Unable to load crypto library ... EVP_MD_CTX_get_size_ex: symbol not found. If you write your own two-stage Dockerfile, build and run on the same distribution release.

Common problems

The release starts but nothing listens

PHX_SERVER isn't set. Set it to true, or start with bin/server (from mix phx.gen.release), which sets it for you.

LiveView pages reload in a loop

The WebSocket is being refused by the origin check. Set PHX_HOST to the domain in the browser's address bar.

environment variable DATABASE_URL is missing

runtime.exs requires it in production. Attach a database, or remove the Repo from your supervision tree if the app has none.

Unable to load crypto library

The runtime image's OpenSSL is a different version from the one the release was built against. Build and run on the same base image.

Frequently asked questions

Do I need Elixir installed on the server?

No. A release includes the Erlang runtime (ERTS) and your compiled code. The runtime image needs only the system libraries it links against.

Can I cluster nodes?

Yes, with libcluster and a discovery strategy for your platform (DNS or the Kubernetes API). A single node is enough for most apps; LiveView and PubSub work within one node with no extra setup.

Which Elixir version?

At least the one mix.exs names: our project, generated on Elixir 1.17, says elixir: "~> 1.17". Pick that version or newer in your host.

Give every customer their own stack

Staxa provisions an isolated app, database, subdomain and TLS per customer from one API call. The Free plan includes 2 tenants.