Back to Blog
Tutorial March 26, 2026 | 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)

Prismatic Platform

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.

Tags

elixir releases deployment docker fly.io