Deployment
Deploying Elixir/Phoenix applications with Mix releases, Docker, and Fly.io
You are an expert in deploying Elixir and Phoenix applications to production. ## Key Points - **Mix Release**: Produces a self-contained directory with the app, dependencies, and ERTS (Erlang Runtime System). - **Runtime Configuration**: `config/runtime.exs` runs at application boot (not compile time), reading environment variables. - **Release Commands**: Custom commands (like database migrations) that run in the release environment. - **Health Checks**: Endpoints that deployment platforms use to verify the app is running. - **Clustering**: Connecting multiple BEAM nodes for distributed features (PubSub, Presence, distributed tasks). - Always use `config/runtime.exs` for secrets and environment-specific values. Never compile secrets into the release. - Run migrations as a release command (`release_command` in Fly.io, or an init container in Kubernetes) so they complete before traffic is routed. - Use multi-stage Docker builds to keep the final image small — the runner image does not need Elixir or build tools. - Set `server: true` in the endpoint config for releases (Phoenix does not auto-start the server in release mode without it). - Enable clustering with `dns_cluster` on Fly.io so PubSub, Presence, and distributed features work across multiple machines. - Use `bin/my_app remote` for production debugging via a remote IEx shell. - **Compile-time vs. runtime config**: Values in `config/config.exs` and `config/prod.exs` are baked into the release at compile time. Environment variables must be read in `runtime.exs`.
skilldb get elixir-phoenix-skills/DeploymentFull skill: 284 linesDeployment — Elixir/Phoenix
You are an expert in deploying Elixir and Phoenix applications to production.
Overview
Elixir applications are deployed as self-contained releases built with mix release. A release bundles the compiled BEAM bytecode, the Erlang runtime, and configuration into a single artifact. Common deployment targets include Fly.io, bare metal with systemd, and container platforms via Docker.
Core Philosophy
Elixir releases represent a mature approach to deployment inherited from decades of Erlang/OTP production experience. A release is a self-contained artifact that bundles your compiled application, its dependencies, and the Erlang runtime into a single deployable unit. This means no compiler, no mix, and no source code on your production server — just the bytecode needed to run.
The strict separation between compile-time and runtime configuration is not a quirk — it is a deliberate design choice. Values in config/config.exs and config/prod.exs are baked into the release at build time. Environment variables, secrets, and anything that varies between environments must be read in config/runtime.exs, which executes when the application boots. Getting this wrong is the single most common source of deployment failures in Elixir.
Clustering is a first-class deployment concern for Phoenix applications that use PubSub, Presence, or any distributed feature. Unlike most web frameworks where each instance is an island, BEAM nodes can form a cluster where processes communicate seamlessly across machines. This is powerful but requires explicit network configuration, service discovery, and an understanding that your application's behavior changes when it runs on multiple nodes.
Anti-Patterns
-
Compile-Time Secrets: Putting database URLs, API keys, or secret key bases in
config/prod.exsinstead ofconfig/runtime.exs. These values get compiled into the release and cannot be changed without rebuilding. Worse, they may end up in your Docker image layers. -
Skipping the Health Check: Deploying without an endpoint that verifies database connectivity, application boot, and critical service availability. Deployment platforms use health checks to route traffic and decide when a new instance is ready. Without one, broken deployments receive live traffic.
-
Ignoring Clustering Requirements: Deploying multiple instances of a Phoenix app that uses PubSub or Presence without configuring node discovery (e.g.,
dns_cluster). Each instance operates in isolation, meaning PubSub messages and Presence data are not shared, leading to inconsistent real-time behavior. -
Fat Docker Images: Including the full Elixir/Erlang build toolchain, source code, and test dependencies in the production Docker image. Use multi-stage builds where the builder stage compiles the release and the runner stage contains only the runtime dependencies and compiled artifacts.
-
Manual Migration Execution: SSH-ing into production to run migrations manually, or forgetting to run them entirely. Migrations should be automated as a release command that executes before the new version receives traffic, ensuring the database schema is always in sync with the deployed code.
Core Concepts
- Mix Release: Produces a self-contained directory with the app, dependencies, and ERTS (Erlang Runtime System).
- Runtime Configuration:
config/runtime.exsruns at application boot (not compile time), reading environment variables. - Release Commands: Custom commands (like database migrations) that run in the release environment.
- Health Checks: Endpoints that deployment platforms use to verify the app is running.
- Clustering: Connecting multiple BEAM nodes for distributed features (PubSub, Presence, distributed tasks).
Implementation Patterns
Runtime Configuration
# config/runtime.exs
import Config
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise "DATABASE_URL environment variable is not set"
config :my_app, MyApp.Repo,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
ssl: true,
ssl_opts: [verify: :verify_none]
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise "SECRET_KEY_BASE environment variable is not set"
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, 0, 0, 0, 0}, port: port],
secret_key_base: secret_key_base,
server: true
end
Release Migration Module
defmodule MyApp.Release do
@moduledoc "Release tasks that run outside of the application."
@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.ensure_all_started(:ssl)
Application.load(@app)
end
end
Dockerfile
# Stage 1: Build
ARG ELIXIR_VERSION=1.16.1
ARG OTP_VERSION=26.2.2
ARG DEBIAN_VERSION=bookworm-20240130-slim
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
FROM ${BUILDER_IMAGE} AS builder
RUN apt-get update -y && apt-get install -y build-essential git && \
apt-get clean && rm -f /var/lib/apt/lists/*_*
WORKDIR /app
RUN mix local.hex --force && mix local.rebar --force
ENV MIX_ENV=prod
COPY mix.exs mix.lock ./
RUN mix deps.get --only prod
RUN mkdir config
COPY config/config.exs config/prod.exs config/
RUN mix deps.compile
COPY priv priv
COPY lib lib
COPY assets assets
RUN mix assets.deploy
RUN mix compile
COPY config/runtime.exs config/
RUN mix release
# Stage 2: Run
FROM ${RUNNER_IMAGE}
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
WORKDIR /app
RUN chown nobody /app
ENV MIX_ENV=prod
COPY --from=builder --chown=nobody:root /app/_build/prod/rel/my_app ./
USER nobody
CMD ["/app/bin/server"]
Fly.io Configuration
# fly.toml
app = "my-app"
primary_region = "iad"
kill_signal = "SIGTERM"
kill_timeout = "5s"
[build]
[deploy]
release_command = "/app/bin/migrate"
[env]
PHX_HOST = "my-app.fly.dev"
POOL_SIZE = "10"
[http_service]
internal_port = 4000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 1
[http_service.concurrency]
type = "connections"
hard_limit = 1000
soft_limit = 800
[[vm]]
memory = "512mb"
cpu_kind = "shared"
cpus = 1
Fly.io Deployment Commands
# Initial setup
fly launch
fly secrets set SECRET_KEY_BASE=$(mix phx.gen.secret)
fly secrets set DATABASE_URL=postgres://...
# Create Postgres database
fly postgres create --name my-app-db
fly postgres attach my-app-db
# Deploy
fly deploy
# Run migrations manually
fly ssh console -C "/app/bin/migrate"
# Open remote IEx console
fly ssh console -C "/app/bin/my_app remote"
# Scale
fly scale count 2
fly scale vm shared-cpu-2x
Clustering on Fly.io
# In mix.exs deps
{:dns_cluster, "~> 0.1.1"}
# In application.ex children list
{DNSCluster, query: Application.get_env(:my_app, :dns_cluster_query) || :ignore}
# In config/runtime.exs
config :my_app, dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY")
# In fly.toml env
[env]
DNS_CLUSTER_QUERY = "my-app.internal"
Health Check Endpoint
# In router.ex
get "/health", MyAppWeb.HealthController, :check
# Controller
defmodule MyAppWeb.HealthController do
use MyAppWeb, :controller
def check(conn, _params) do
case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1") do
{:ok, _} ->
json(conn, %{status: "ok"})
{:error, _} ->
conn
|> put_status(503)
|> json(%{status: "error", message: "database unavailable"})
end
end
end
Best Practices
- Always use
config/runtime.exsfor secrets and environment-specific values. Never compile secrets into the release. - Run migrations as a release command (
release_commandin Fly.io, or an init container in Kubernetes) so they complete before traffic is routed. - Use multi-stage Docker builds to keep the final image small — the runner image does not need Elixir or build tools.
- Set
server: truein the endpoint config for releases (Phoenix does not auto-start the server in release mode without it). - Enable clustering with
dns_clusteron Fly.io so PubSub, Presence, and distributed features work across multiple machines. - Use
bin/my_app remotefor production debugging via a remote IEx shell.
Common Pitfalls
- Compile-time vs. runtime config: Values in
config/config.exsandconfig/prod.exsare baked into the release at compile time. Environment variables must be read inruntime.exs. - Missing ERTS in Docker: The runner image must have compatible system libraries (libstdc++, openssl, ncurses). A mismatch causes cryptic startup failures.
- Not setting
PHX_HOST: Without the correct host, URL generation and WebSocket connections break in production. - Forgetting
fly secrets: Environment variables set infly.toml[env]are visible in the Fly dashboard. Usefly secrets setfor sensitive values. - Database connection limits: Fly Postgres free tier allows limited connections. Set
POOL_SIZEappropriately and consider PgBouncer for connection pooling.
Install this skill directly: skilldb add elixir-phoenix-skills
Related Skills
Channels
Phoenix Channels and PubSub for real-time bidirectional communication
Concurrency
Elixir processes and message passing for concurrent and parallel programming
Ecto
Ecto patterns for database schemas, queries, changesets, and migrations in Elixir
Genserver
GenServer patterns for stateful processes in Elixir OTP applications
Otp Supervision
OTP supervision tree design for building fault-tolerant Elixir applications
Phoenix Liveview
Phoenix LiveView patterns for building real-time, server-rendered interactive UIs