Phoenix is one of the few frameworks that ships its own production deployment tooling and actually means it. mix release produces a self-contained OTP release, and a single generator writes you a production-grade Dockerfile. The catch is that releases behave differently from mix phx.server in ways that only surface in production: Mix isn't available anymore, the web server doesn't start unless you tell it to, and LiveView will silently drop websocket connections if one env var is wrong. Here's the whole path, gotchas included.
Generate the release tooling
Phoenix has a generator that sets up everything release-related in one shot. Run it with the --docker flag:
mix phx.gen.release --dockerThis creates:
Dockerfileand.dockerignore— a multi-stage build tuned for Phoenixlib/my_app/release.ex— a module for running migrations without Mixrel/overlays/bin/server— a launch script that setsPHX_SERVER=trueand starts the releaserel/overlays/bin/migrate— a script that runs your Ecto migrations
Commit all of it. These four files are the difference between "works on my machine" and a deploy you can reason about.
Why the server script matters
Here's the first gotcha. In production config, the endpoint only starts its web server when server: true is set, and the generated config/runtime.exs keys that off an environment variable:
if System.get_env("PHX_SERVER") do
config :my_app, MyAppWeb.Endpoint, server: true
endIf you start the release with bin/my_app start and forget PHX_SERVER=true, the BEAM boots, supervisors come up, your Repo connects — and nothing listens on any port. The app looks alive and serves nothing. This is the single most common "Phoenix deploy is broken" report, and it's why the generated bin/server script exists: it sets the variable and starts the release. The generated Dockerfile uses it as the default command:
CMD ["/app/bin/server"]Leave that alone and you'll never hit the silent-no-listener failure.
Runtime configuration
Everything environment-specific lives in config/runtime.exs, which is evaluated at boot — not at compile time. The generated file expects three variables and fails loudly if the critical ones are missing:
database_url =
System.get_env("DATABASE_URL") ||
raise "environment variable DATABASE_URL is missing."
config :my_app, MyApp.Repo,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise "environment variable SECRET_KEY_BASE is missing."
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
config :my_app, MyAppWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [ip: {0, 0, 0, 0}, port: port],
secret_key_base: secret_key_baseThe raise-at-boot pattern is correct — you want a crashed boot with a clear message, not a running app that 500s on its first request. Generate the secret:
mix phx.gen.secretand set it as SECRET_KEY_BASE. The ip: {0, 0, 0, 0} binding is already in the generated config, so unlike many frameworks you don't need to fix a localhost-only default.
So the environment variables you need in production:
| Variable | Value |
|---|---|
SECRET_KEY_BASE | output of mix phx.gen.secret |
PHX_HOST | your real domain, e.g. myapp.example.com |
DATABASE_URL | injected automatically when the DB is attached |
POOL_SIZE | optional, defaults to 10 |
Migrations without Mix
Second gotcha: an OTP release does not contain Mix. mix ecto.migrate doesn't exist in production. This is what lib/my_app/release.ex is for — it loads the app and runs migrations through Ecto's migrator directly:
defmodule MyApp.Release do
@app :my_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
# ...
endThe generated bin/migrate script wraps it:
/app/bin/my_app eval MyApp.Release.migrateRun this as a release step before the new version takes traffic. Ecto records applied migrations in schema_migrations and locks during the run, so concurrent instances won't double-apply. Whatever you do, don't call migrate from your application's start/2 — a bad migration would then crash-loop the app instead of failing one deploy cleanly.
Assets and the generated Dockerfile
The generated Dockerfile already handles assets — mix assets.deploy runs esbuild/Tailwind and digests static files inside the build stage. The shape is:
# build stage: hexpm/elixir image
RUN mix deps.get --only prod
RUN mix deps.compile
RUN mix assets.deploy
RUN mix compile
RUN mix release
# runtime stage: slim Debian
COPY --from=builder /app/_build/prod/rel/my_app ./
USER nobody
CMD ["/app/bin/server"]The runtime image contains the release and nothing else — no Elixir, no Erlang installation (the release bundles ERTS), no source. Copying mix.exs/mix.lock and fetching deps before copying the rest of the source means dependency compilation is cached across builds; a routine code push only recompiles your app.
The LiveView websocket gotcha
Third gotcha, and the sneakiest. Phoenix checks the Origin header on websocket connections against the endpoint's configured host. If PHX_HOST doesn't match the domain users actually hit, HTTP requests work fine but every LiveView connection dies and falls back to long-poll or just reconnect-loops. The page renders, then goes inert.
The fix is simply setting PHX_HOST to your real domain. If you serve the app from more than one domain, configure check_origin explicitly in runtime.exs:
config :my_app, MyAppWeb.Endpoint,
check_origin: ["https://myapp.example.com", "https://www.example.com"]If LiveView works locally and "doesn't update" in production, check this before anything else.
Pool size vs. connection limits
Ecto's pool defaults to 10 connections per instance. That's worth a moment of thought against your database's connection limit: on PandaStack the free tier allows 50 connections, Pro allows 300, Premium 1000. One instance at POOL_SIZE=10 is comfortable on free; if you scale to several instances, do the multiplication before the database starts refusing connections. Migrations briefly take a couple of connections of their own.
Deploying on PandaStack
With phx.gen.release --docker committed, the platform side is short:
- 1Create a managed PostgreSQL instance (14.x or 16.x) from the dashboard.
- 2Connect your repo as a container app. PandaStack picks up the Dockerfile and builds it with rootless BuildKit in an ephemeral Kubernetes job — live build logs stream as it goes, which matters for Elixir because a full cold dependency compile is where the time goes.
- 3Attach the database.
DATABASE_URLis injected automatically — the raise inruntime.exsnever fires, and no credentials get pasted anywhere. - 4Set
SECRET_KEY_BASEandPHX_HOSTin the app's environment variables. - 5Run migrations via
/app/bin/migrateas a release step, then push. Everygit pushafter that rebuilds and redeploys.
Custom domains come with automatic SSL, so once DNS points at the app, set PHX_HOST to the final domain and LiveView sockets connect cleanly.
One free-tier note: idle apps scale to zero and cold-start on the next request. The BEAM boots fast, but if you're demoing LiveView to someone, give the first request a beat. Paid tiers run on stable nodes without scale-to-zero.
The short checklist
mix phx.gen.release --dockeroutput committedSECRET_KEY_BASEandPHX_HOSTset in the environment- Migrations via
bin/migrate(release step), never instart/2 check_origin/PHX_HOSTmatching the real domain before blaming LiveViewPOOL_SIZE× instances < your plan's connection limit
Phoenix rewards doing deployment its way — the generators encode years of production lessons, and fighting them is how you rediscover those lessons one outage at a time. If you want to see the push-to-live loop end to end, it's a quick experiment on https://pandastack.io.