Skip to main content
Technology & EngineeringElixir Phoenix284 lines

Deployment

Deploying Elixir/Phoenix applications with Mix releases, Docker, and Fly.io

Quick Summary18 lines
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 lines
Paste into your CLAUDE.md or agent config

Deployment — 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.exs instead of config/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.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).

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

Common Pitfalls

  • 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.
  • 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 in fly.toml [env] are visible in the Fly dashboard. Use fly secrets set for sensitive values.
  • Database connection limits: Fly Postgres free tier allows limited connections. Set POOL_SIZE appropriately and consider PgBouncer for connection pooling.

Install this skill directly: skilldb add elixir-phoenix-skills

Get CLI access →