tutorial 11 min read

Building and Deploying Elixir Releases

Complete guide to Elixir releases covering mix release configuration, runtime.exs, Docker multi-stage builds, Fly.io deployment, and hot upgrade patterns.

Tomas Korcak (korczis) 363 words

An Elixir release is a self-contained package of your application, the Erlang runtime, and all dependencies. It runs without Elixir or Mix installed on the target system. This post covers the full release pipeline from configuration through Docker builds to Fly.io deployment.

Release Configuration

Define your release in mix.exs:

def project do
  [
    apps_path: "apps",
    releases: [
      prismatic: [
        applications: [
          prismatic_web: :permanent,
          prismatic_storage: :permanent,
          prismatic_dd: :permanent,
          prismatic_osint: :permanent,
          prismatic_auth: :permanent
        ],
        include_executables_for: [:unix],
        steps: [:assemble, :tar]
      ]
    ]
  ]
end

For umbrella projects, explicitly list which applications to include. Not every app needs to be in every release. You might have separate releases for web, workers, and migration tasks.

Runtime Configuration

The config/runtime.exs file is evaluated when the release starts, not at compile time. This is where all deployment-specific values go:

import Config

database_url =
  System.get_env("DATABASE_URL") ||
    raise """
    environment variable DATABASE_URL is missing.
    For example: ecto://USER:PASS@HOST/DATABASE
    """

config :prismatic_storage, Prismatic.Repo,
  url: database_url,
  pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
  ssl: System.get_env("DATABASE_SSL") == "true"

if config_env() == :prod do
  secret_key_base =
    System.get_env("SECRET_KEY_BASE") ||
      raise "SECRET_KEY_BASE environment variable is missing"

  config :prismatic_web, PrismaticWeb.Endpoint,
    http: [
      port: String.to_integer(System.get_env("PORT") || "4000"),
      transport_options: [socket_opts: [:inet6]]
    ],
    secret_key_base: secret_key_base,
    server: true
end

Building the Release

# Set environment
export MIX_ENV=prod

# Compile and build
mix deps.get --only prod
mix compile
mix assets.deploy
mix release prismatic

The release is output to _build/prod/rel/prismatic/. The directory structure:

_build/prod/rel/prismatic/
  bin/
    prismatic          # Start/stop/remote console
    migrate            # Custom migration command
  lib/
    prismatic_web-0.1.0/
    prismatic_dd-0.1.0/
    ...
  releases/
    0.1.0/
      env.sh           # Environment setup
      vm.args          # BEAM VM arguments
  erts-14.0/           # Erlang runtime

Custom Release Commands

Add migration and seed commands that run without Mix:

# lib/prismatic/release.ex
defmodule Prismatic.Release do
  @moduledoc """
  Release commands for database migrations and seeds.
  Run via: bin/prismatic eval "Prismatic.Release.migrate()"
  """

  @app :prismatic_storage

  @spec migrate() :: :ok
  def migrate do
    load_app()

    for repo <- repos() do
      {:ok, _, _} =
        Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end

    :ok
  end

  @spec rollback(module(), integer()) :: :ok
  def rollback(repo, version) do
    load_app()
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
    :ok
  end

  defp repos do
    Application.fetch_env!(@app, :ecto_repos)
  end

  defp load_app do
    Application.load(@app)
  end
end

Docker Multi-Stage Build

A production Dockerfile for an Elixir umbrella:

# Stage 1: Build
FROM elixir:1.17-otp-27-alpine AS builder

RUN apk add --no-cache build-base git npm

WORKDIR /app

ENV MIX_ENV=prod

# Install hex and rebar
RUN mix local.hex --force && mix local.rebar --force

# Copy dependency manifests first (Docker layer caching)
COPY mix.exs mix.lock ./
COPY apps/prismatic_web/mix.exs apps/prismatic_web/
COPY apps/prismatic_storage/mix.exs apps/prismatic_storage/
COPY apps/prismatic_dd/mix.exs apps/prismatic_dd/
COPY apps/prismatic_osint/mix.exs apps/prismatic_osint/
COPY apps/prismatic_auth/mix.exs apps/prismatic_auth/

RUN mix deps.get --only prod
RUN mix deps.compile

# Copy application code
COPY apps/ apps/
COPY config/ config/
COPY priv/ priv/

# Build assets
RUN cd apps/prismatic_web && npm ci --prefix assets && npm run deploy --prefix assets
RUN mix assets.deploy

# Compile and build release
RUN mix compile
RUN mix release prismatic

# Stage 2: Runtime
FROM alpine:3.19 AS runtime

RUN apk add --no-cache libstdc++ openssl ncurses-libs

WORKDIR /app

COPY --from=builder /app/_build/prod/rel/prismatic ./

ENV HOME=/app
ENV PORT=4000

EXPOSE 4000

CMD ["bin/prismatic", "start"]

The multi-stage build ensures the final image contains only the runtime -- no Elixir, no Mix, no source code. Image size drops from 1.5GB to ~80MB.

Fly.io Deployment

The fly.toml configuration:

app = "prismatic-prod"
primary_region = "fra"

[build]
  dockerfile = "Dockerfile"

[env]
  PHX_HOST = "prismatic.fly.dev"
  POOL_SIZE = "10"
  ECTO_IPV6 = "true"

[http_service]
  internal_port = 4000
  force_https = true
  auto_stop_machines = false
  auto_start_machines = true
  min_machines_running = 1

  [http_service.concurrency]
    type = "connections"
    hard_limit = 1000
    soft_limit = 800

[[vm]]
  size = "shared-cpu-2x"
  memory = "1gb"

[deploy]
  release_command = "bin/prismatic eval Prismatic.Release.migrate"

Deploy with:

fly deploy --strategy rolling

The release_command runs migrations automatically before the new version starts accepting traffic.

Health Checks

Configure health checks to ensure the new release is healthy before routing traffic:

[[services.http_checks]]
  interval = 10000
  timeout = 2000
  grace_period = "30s"
  method = "GET"
  path = "/api/v1/health"

The health endpoint should verify critical dependencies:

defmodule PrismaticWeb.HealthController do
  use PrismaticWeb, :controller

  @spec check(Plug.Conn.t(), map()) :: Plug.Conn.t()
  def check(conn, _params) do
    checks = %{
      database: check_database(),
      memory: check_memory(),
      uptime: System.monotonic_time(:second)
    }

    status = if Enum.all?(Map.values(checks), &(&1 == :ok or is_integer(&1))), do: 200, else: 503

    json(conn, %{status: status_label(status), checks: checks})
  end

  defp check_database do
    case Prismatic.Repo.query("SELECT 1") do
      {:ok, _} -> :ok
      {:error, _} -> :error
    end
  end

  defp check_memory do
    memory = :erlang.memory(:total)
    if memory < 900_000_000, do: :ok, else: :warning
  end

  defp status_label(200), do: "healthy"
  defp status_label(_), do: "unhealthy"
end

VM Configuration

Tune the BEAM VM for production via rel/vm.args.eex:

## Increase process limit
+P 1000000

## Increase port limit
+Q 65536

## Enable SMP
-smp auto

## Set scheduler count
+S 4:4

## Enable kernel poll
+K true

## Set async thread pool size
+A 64

## Set distribution buffer busy limit
+zdbbl 8192

## Enable crash dump
-env ERL_CRASH_DUMP /tmp/erl_crash.dump

Release Upgrades

For zero-downtime deployments on Fly.io, use rolling deploys:

# Deploy with rolling strategy (default)
fly deploy --strategy rolling

# Deploy to canary first
fly deploy --strategy canary

Rolling deploys start new machines, wait for health checks, then drain old machines. Traffic shifts gradually from old to new.

For true hot code upgrades (replacing code in a running BEAM), you need Distillery or manual appup files. This is rarely worth the complexity for web applications where rolling deploys achieve the same result.

Summary

StepCommand/FilePurpose
Configure releasemix.exs releases keyDefine included apps
Runtime configconfig/runtime.exsDeploy-specific values
Build releasemix release prismaticPackage application
Docker buildMulti-stage DockerfileMinimal production image
Deployfly deployShip to production
Migraterelease_commandAuto-run migrations
Health check/api/v1/healthVerify deployment health
VM tuningrel/vm.args.eexBEAM performance

Releases are the production standard for Elixir. They eliminate runtime dependencies, provide consistent deployments, and enable proper operational tooling.