Deploying Phoenix on a Small Container — Single Node, and That's Fine

·5 min read·Updated
elixirphoenixliveviewbeamdeploymenthow-to

Elixir and Phoenix suit a small container unusually well: the BEAM is efficient at holding many idle connections, LiveView costs little per connected user, and a mix release produces a self-contained artifact with no runtime to install. There's one structural limitation to settle up front, because it's the first thing Elixir developers ask: you can't cluster. There's no private networking and an app runs as a single container, so distributed Erlang, libcluster, and multi-node Horde or Presence setups have nothing to connect to. A single-node Phoenix app is fully functional — PubSub, Presence, and LiveView all work within one node — you just scale it vertically rather than horizontally.

What single-node actually costs you

Less than the Elixir ecosystem's emphasis on distribution suggests.

Still works, unchanged: Phoenix.PubSub (one node is the simple case), Phoenix.Presence within that node, LiveView, GenServers, supervision trees, ETS, :persistent_term, background work via Task.Supervisor or Oban.

Doesn't apply: libcluster node discovery, :global registration across nodes, distributed Horde, cross-node Presence, and any design that assumes a second BEAM to fail over to.

For most Phoenix apps — a web app, an API, a LiveView dashboard — the first list is everything you use. The second matters when you're operating at a scale where you'd want multiple machines anyway, and at that point this isn't the platform for it.

One genuine consequence: there's no failover. A single node means a deploy or a crash is a gap in service, not a rolling handover.

Releases, and the environment they need

Build with mix release and let config/runtime.exs do the configuration. Phoenix's guidance is explicit about why: "the more configuration you can provide at runtime (using config/runtime.exs), the more reusable your images will be across environments", and "any environment variables that are needed at runtime should be read in config/runtime.exs, not scattered throughout your code" (Phoenix docs).

That advice lines up exactly with how this platform works, because the image build receives no environment variables and no build arguments — anything you try to read at compile time will be empty. Runtime configuration isn't just tidier here, it's the only thing that works.

Three variables to set as environment variables on the app:

  • SECRET_KEY_BASE — generate with mix phx.gen.secret. The docs are clear that it shouldn't be baked in: secrets should be "provided when creating containers based on that image. This is why the Endpoint's :secret_key_base is configured in config/runtime.exs by default."
  • PHX_HOST — your app's public hostname, so generated URLs and cookies are correct.
  • DATABASE_URL — if you're using Postgres. Nothing injects this; you deploy Postgres as its own app and set the value yourself.

And check the endpoint actually starts. mix phx.gen.release produces a server script described as "a server script for conveniently invoking the release system with environment variables to start the phoenix web server" — because a release doesn't start the web server unless told to. Look at your config/runtime.exs: if it starts the endpoint only when a variable is present, set that variable, or run the server script as your command rather than start.

Bind all interfaces

The endpoint must listen on all interfaces, not loopback:

http: [ip: {0, 0, 0, 0}, port: port]

{127, 0, 0, 1} is reachable only from inside the container. Nothing probes your app, so a loopback binding deploys cleanly and simply never answers.

Tell the BEAM how many schedulers it may have

The BEAM starts one scheduler per logical CPU it detects, and a container sees the host's cores rather than your quota. On a large machine that means a scheduler count wildly out of proportion to a CPU allocation that is a time budget shared by the whole container.

Set it explicitly, without touching code:

ERL_FLAGS=+S 2:2

This is the same trap that catches Go before 1.25, Python's default thread pool, Node's cluster, and tokio — every runtime that sizes its parallelism from nproc. On a fractional CPU, a small number is right.

Migrations without mix

A release doesn't include Mix, so mix ecto.migrate isn't available in production. The standard answer is a release module:

defmodule MyApp.Release do
  def migrate do
    Application.load(:my_app)
    for repo <- Application.fetch_env!(:my_app, :ecto_repos) do
      {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end
  end
end

Then run it deliberately — from the app's shell — rather than wiring it into startup. Startup code runs on every container start here, including every iteration of a crash loop, which is a bad place to be applying schema changes.

LiveView and redeploys

Worth planning for, because LiveView makes it visible. Every deploy replaces the container, so every LiveView socket drops at once. LiveView reconnects on its own, which handles the mechanics — but the process backing each connection is gone, and with it any state held only in its assigns.

Two habits:

  • Keep recoverable state in the URL via handle_params, so a reconnect lands the user where they were rather than at a reset form.
  • Expect every client to reconnect simultaneously, because they all disconnected simultaneously. That stampede is worth designing for.

Watch the asset build

Elixir compilation is manageable, but mix assets.deploy runs esbuild and Tailwind, and the build container has roughly 0.75 GB. That's usually where a Phoenix build fails, not in the Erlang compile.

If it doesn't fit, build in CI and push the image — which also means you can set build-time variables properly, and you get a faster build than a half-vCPU can deliver.

FAQ

Can I run an Elixir cluster?

No. There's no private networking and an app is a single container, so libcluster has no peers to find. Single-node Phoenix is fully functional.

Does Phoenix PubSub work on one node?

Yes. One node is the straightforward case — it's cross-node distribution that isn't available.

Why doesn't my Phoenix release serve requests?

A release doesn't start the endpoint unless told to. Check what config/runtime.exs requires, or use the generated server script as your run command.

Where do I set SECRET_KEY_BASE?

As an environment variable on the app, read in config/runtime.exs. Don't bake it into the image.

How many schedulers should the BEAM use?

Few. It defaults to the host's core count, which has nothing to do with your quota. Set ERL_FLAGS=+S to something small.

How do I run migrations?

Through a release module invoked deliberately, since mix isn't in the release. Avoid running them automatically at startup.

Wrapping up

Phoenix deploys here like any other release-based app, and the one thing you give up — clustering — is a smaller loss than the ecosystem's culture around distribution implies. A single BEAM node with a few hundred megabytes handles a surprising amount of LiveView. Configure at runtime because compile-time variables aren't available anyway, bind all interfaces, cap the schedulers, and treat every deploy as an event your LiveView clients will notice and recover from.