Back to Blog
Tutorial7 min read2026-07-12

How to Deploy Phoenix (Elixir) with PostgreSQL on PandaStack

Deploy Phoenix the right way: mix releases, the generated Dockerfile, migrations without Mix, LiveView origin checks, and Postgres via DATABASE_URL.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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

This creates:

  • Dockerfile and .dockerignore — a multi-stage build tuned for Phoenix
  • lib/my_app/release.ex — a module for running migrations without Mix
  • rel/overlays/bin/server — a launch script that sets PHX_SERVER=true and starts the release
  • rel/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
end

If 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_base

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

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

VariableValue
SECRET_KEY_BASEoutput of mix phx.gen.secret
PHX_HOSTyour real domain, e.g. myapp.example.com
DATABASE_URLinjected automatically when the DB is attached
POOL_SIZEoptional, 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
  # ...
end

The generated bin/migrate script wraps it:

/app/bin/my_app eval MyApp.Release.migrate

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

  1. 1Create a managed PostgreSQL instance (14.x or 16.x) from the dashboard.
  2. 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.
  3. 3Attach the database. DATABASE_URL is injected automatically — the raise in runtime.exs never fires, and no credentials get pasted anywhere.
  4. 4Set SECRET_KEY_BASE and PHX_HOST in the app's environment variables.
  5. 5Run migrations via /app/bin/migrate as a release step, then push. Every git push after 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 --docker output committed
  • SECRET_KEY_BASE and PHX_HOST set in the environment
  • Migrations via bin/migrate (release step), never in start/2
  • check_origin/PHX_HOST matching the real domain before blaming LiveView
  • POOL_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.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also