Releases and Deployment
Deploying Elixir is where the language's heritage shows up most. The BEAM is a runtime designed for long-running, distributed, hot-upgradable systems — and mix release packages your app into something the BEAM was always meant to run: a self-contained release with the runtime baked in, a startup script, and configuration loaded at runtime.
What this means in practice: you get a tarball or a Docker image, you put it on a server, and you run a script. No Elixir installation needed on the target. No mix in production. Memory and CPU footprint that's a fraction of a typical Rails or Node app. And a deployment story that's been used to run telecom switches with five-nines uptime since the early '90s.
What mix release Does
mix release is a built-in Mix task. Run it, and you get a directory under _build/prod/rel/my_app/ containing:
- The Erlang runtime (ERTS), so the target server doesn't need Elixir installed.
- Your app and its dependencies, compiled to BEAM files.
- A
bin/my_appscript that starts, stops, and connects to the running system. - Compiled configuration.
MIX_ENV=prod mix release
_build/prod/rel/my_app/bin/my_app start # foreground
_build/prod/rel/my_app/bin/my_app daemon # background
_build/prod/rel/my_app/bin/my_app remote # connect a remote shell
_build/prod/rel/my_app/bin/my_app stop
The release tarball is typically 30-50MB. That includes the Erlang VM. There's no npm install step on the server, no compilation, no shared library hunt. You unpack and run.
Configure the release in mix.exs:
def project do
[
app: :my_app,
version: "0.1.0",
releases: [
my_app: [
include_executables_for: [:unix],
applications: [runtime_tools: :permanent],
steps: [:assemble, :tar]
]
]
]
end
include_executables_for: [:unix] produces shell scripts; for Windows, add :windows. The :tar step builds a .tar.gz you can scp anywhere.
Runtime Configuration
The single most important configuration file in a real Elixir app is config/runtime.exs. It runs at startup time, after the release boots, with access to environment variables.
import Config
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise "DATABASE_URL is not set"
config :my_app, MyApp.Repo,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: maybe_ipv6()
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise "SECRET_KEY_BASE is not set"
config :my_app, MyAppWeb.Endpoint,
http: [
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: String.to_integer(System.get_env("PORT") || "4000")
],
secret_key_base: secret_key_base,
server: true
end
config/config.exs is compile-time — values there are baked into the release. config/runtime.exs is the right place for anything that comes from the environment: secrets, database URLs, feature flags, anything that varies between staging and production.
The pattern: anything that could conceivably differ across environments goes in runtime.exs, read from System.get_env/1. Anything that's always the same (compile-time configuration of dependencies, application metadata) goes in config.exs.
Docker for Elixir
The standard pattern for containerizing an Elixir app is a multi-stage Dockerfile. Build the release in one stage, copy it to a slim runtime image in another.
FROM hexpm/elixir:1.16.0-erlang-26.2-debian-bookworm-20231009-slim AS build
ENV MIX_ENV=prod
WORKDIR /app
RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
RUN mix local.hex --force && mix local.rebar --force
COPY mix.exs mix.lock ./
RUN mix deps.get --only prod
RUN mix deps.compile
COPY config/config.exs config/prod.exs config/
COPY priv priv
COPY lib lib
# Phoenix asset build
COPY assets assets
RUN mix assets.deploy
COPY config/runtime.exs config/
RUN mix release
FROM debian:bookworm-slim AS app
RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8
WORKDIR /app
RUN chown nobody /app
USER nobody
COPY --from=build --chown=nobody:root /app/_build/prod/rel/my_app ./
ENV HOME=/app
EXPOSE 4000
CMD ["/app/bin/my_app", "start"]
The final image is usually 100-150MB. Most of that is the Debian base and locale data; the Elixir release itself is small.
Migrations in Releases
You can't run mix ecto.migrate against a release because there's no Mix in the runtime image. The standard pattern is a release task module:
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
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
Application.load(@app)
end
end
Run it from the release:
bin/my_app eval "MyApp.Release.migrate()"
In Docker, this becomes docker run myapp eval "MyApp.Release.migrate()". Most platforms (Fly, Render, Gigalixir) have a way to wire this into the deploy process so migrations run before the new version starts serving traffic.
Deploying to Fly.io
Fly is the most popular platform for Elixir deployments right now. They have first-class clustering support, the global edge network plays well with LiveView's WebSocket connections, and the CLI is genuinely good.
fly launch # generates fly.toml, sets up Postgres, deploys
fly deploy # subsequent deploys
A typical fly.toml:
app = "my-app"
primary_region = "ord"
[build]
[env]
PHX_HOST = "my-app.fly.dev"
PORT = "8080"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
[deploy]
release_command = "/app/bin/my_app eval MyApp.Release.migrate"
The release_command runs the migration before the new release starts. If migration fails, Fly aborts the deploy and keeps the old version serving.
For clustering across regions, set DNS_CLUSTER_QUERY (libcluster's DNSPoll strategy reads it):
[env]
DNS_CLUSTER_QUERY = "my-app.internal"
RELEASE_DISTRIBUTION = "name"
RELEASE_NODE = "my-app@<fly-private-ip>"
This gives you a multi-node BEAM cluster across Fly machines. Phoenix.PubSub broadcasts span the cluster automatically.
Other Platforms
Gigalixir is a heroku-style platform built specifically for Elixir. Hot upgrades, easy clustering, and a free tier. Good when you want zero infrastructure work and don't mind a higher per-machine cost.
Render is a generic platform that handles Elixir well via its Docker support. Cheaper than Heroku, less Elixir-specific than Fly or Gigalixir, but reliable.
Self-hosting on a VPS is also reasonable — Elixir releases are easy to run on a Linux box. systemd unit, environment variables, a process manager. Discord runs their own infrastructure, not because they had to, but because BEAM operations are tractable enough that the cost-benefit at their scale tips that way.
The choice rarely matters early on. Fly.io is a fine default. Switch when you outgrow the platform or hit a specific need (compliance, custom hardware, weird networking).
Hot Code Upgrades
The BEAM supports replacing modules in a running system without dropping connections — the famous "telecom uptime" feature. In practice, almost no one uses this for web apps. The reason is that mix release doesn't make hot upgrades trivial; you have to manage release versions, write appup files describing the upgrade path, and test the migration carefully.
For most teams, blue-green deploys (start new instances, drain old ones) are simpler and good enough. Phoenix's WebSocket reconnection handles the brief disconnect gracefully — LiveViews remount transparently.
If you do need true hot upgrades (you can't drop in-flight calls in a phone switch, you can't restart a running game session in a multiplayer server), look at Distillery (legacy) or do the upgrade scripts by hand. It's a real capability; it's also a tax on every deploy. Pick it deliberately.
Distributed Elixir Basics
Two BEAM nodes connected via distributed Erlang can send messages to each other's processes as if they were local. Node.connect(:"other@host"), and now :rpc.call/4 and Node.spawn/2 work between them. Phoenix.PubSub broadcasts cross nodes automatically when you use the default :pg adapter.
For production, use libcluster to handle node discovery:
{:libcluster, "~> 3.3"}
# config/runtime.exs
config :libcluster,
topologies: [
myapp: [
strategy: Cluster.Strategy.DNSPoll,
config: [
polling_interval: 5_000,
query: System.get_env("DNS_CLUSTER_QUERY"),
node_basename: "my_app"
]
]
]
# lib/my_app/application.ex
children = [
{Cluster.Supervisor, [topologies, [name: MyApp.ClusterSupervisor]]},
...
]
Now nodes that come up under the same DNS name connect automatically. Phoenix.Presence works across the cluster. PubSub broadcasts span nodes.
The capabilities you get from clustering: shared cache via :ets or :persistent_term (replicated with libraries like Cachex or Nebulex), distributed PubSub (built-in), distributed task execution (Task.Supervisor), and global state via :global (rarely a good idea — prefer single-leader patterns).
The capabilities you don't get: automatic data replication (use a database), exactly-once message delivery (use idempotent messages), or split-brain immunity (configure your cluster strategy carefully).
Common Pitfalls
Putting secrets in config/config.exs. They get baked into the release. Use config/runtime.exs and System.get_env/1.
Forgetting to set server: true on the Phoenix endpoint in production runtime config. Without it, the HTTP server doesn't start, and your release is "running" but serving nothing.
Running the same release on a different OS than you built it on. The Erlang VM is portable, but native dependencies (bcrypt, Postgres SSL libs) are not. Build for the target architecture, or use Docker to standardize.
Deploying without a release_command for migrations. The new code expects new schema; the database has the old schema; production breaks for the duration of the deploy. Wire migrations into the deploy.
Running multi-node clustering without :net_kernel.set_net_ticktime and proper firewall rules. Distributed Erlang requires nodes to talk to each other on dynamic ports. On platforms with private networking (Fly, Render), this works out of the box; on raw VPS, you'll need to configure it.
Treating hot upgrades as a default. They're a real feature, but they require discipline. Blue-green is simpler and usually fine.
Key Takeaways
mix release produces a self-contained tarball with the Erlang runtime, your code, and a startup script — no Elixir installation needed on the target. Use config/runtime.exs for everything environment-specific. Build releases in multi-stage Docker images. Wire migrations through a release task module called from your deploy hook. Fly.io is a strong default for Elixir hosting; Gigalixir, Render, and self-hosted VPS are all reasonable. Use libcluster for node discovery; distributed Elixir gives you cluster-wide PubSub, Presence, and process communication for free. Hot upgrades are a real capability — pick them deliberately, not by default.