Elixir by Example

Containers

Guide Not run when the site is built: it needs tools the build doesn't have Download project

The Releases page ended with a catch: a release contains the Erlang runtime and native code built for the machine that built it, so it must be built on the same OS, CPU architecture and C library as the servers that run it. And those servers still need a few system libraries the runtime links against.

A container image solves both. You build the release inside a Linux image that matches production, then copy just the release into a small image with the libraries it needs. The result runs the same on a laptop, in CI and on any server or cluster that runs containers.

On this page:

  1. The greeter project from the Releases page, unchanged.
  2. A two-stage Dockerfile: one stage builds the release, the other runs it.
  3. Building the image and operating the release inside a container.

The project

The same project as on the Releases page. Nothing in it knows about containers.

mix.exs
defmodule Greeter.MixProject do
  use Mix.Project

  def project do
    [app: :greeter, version: "0.1.0", elixir: "~> 1.20", deps: []]
  end

  def application do
    [mod: {Greeter.Application, []}]
  end
end
lib/greeter/application.ex
defmodule Greeter.Application do
  use Application

  @impl true
  def start(_type, _args) do
    Supervisor.start_link([Greeter.Counter], strategy: :one_for_one, name: Greeter.Supervisor)
  end
end
lib/greeter/counter.ex
defmodule Greeter.Counter do
  use Agent

  def start_link(_opts), do: Agent.start_link(fn -> 0 end, name: __MODULE__)

  def next, do: Agent.get_and_update(__MODULE__, &{&1 + 1, &1 + 1})
end

The Dockerfile

This follows the Dockerfile Phoenix generates (mix phx.gen.release --docker), trimmed to what this project needs: it has no dependencies, configuration or assets.

The first stage starts from an image with Elixir and Erlang installed and runs mix release. The second starts from plain Debian, installs the few libraries the runtime needs, and copies in only the release. The image you ship contains no compiler, no Mix and no source code.

Dockerfile
# Builder and runner use the same Debian release, so the release built in the
# first stage finds the same system libraries in the second.
# Tags: https://hub.docker.com/r/hexpm/elixir/tags
ARG ELIXIR_VERSION=1.20.5
ARG OTP_VERSION=29.1.2
ARG DEBIAN_VERSION=trixie-20261104-slim

ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"

# ---- Stage 1: build the release ----
FROM ${BUILDER_IMAGE} AS builder

WORKDIR /app
RUN mix local.hex --force && mix local.rebar --force
ENV MIX_ENV="prod"

# Dependencies first: this layer is cached until mix.exs changes.
COPY mix.exs ./
RUN mix deps.get --only $MIX_ENV && mix deps.compile

# Then the code, which changes on every commit.
COPY lib lib
RUN mix release

# ---- Stage 2: the image that runs ----
FROM ${RUNNER_IMAGE} AS final

# What the Erlang runtime needs from the OS, and nothing else.
RUN apt-get update \
  && apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates \
  && rm -rf /var/lib/apt/lists/*

RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8

WORKDIR /app
RUN chown nobody /app

# Only the release crosses over: no compiler, no Mix, no source.
COPY --from=builder --chown=nobody:root /app/_build/prod/rel/greeter ./

USER nobody
CMD ["/app/bin/greeter", "start"]

docker build sends the project directory to the builder. Anything listed in .dockerignore stays behind, most importantly a _build from your own machine.

.dockerignore
# Build artifacts from your machine must never reach the image: they were
# compiled for your OS, not the container's.
_build
deps
.git

Build and run

$ docker build -t greeter .
$ docker run -d --rm --name greeter greeter
$ until docker exec greeter /app/bin/greeter pid > /dev/null 2>&1; do sleep 0.1; done

The release script works inside the container just as on the Releases page. docker exec runs it next to the running app, so rpc reaches the same counter every time.

$ docker exec greeter /app/bin/greeter rpc 'IO.puts(Greeter.Counter.next())'
$ docker exec greeter /app/bin/greeter rpc 'IO.puts(Greeter.Counter.next())'

docker stop sends the main process SIGTERM. The Erlang runtime treats that as a request to shut down cleanly, the same as bin/greeter stop.

$ docker stop greeter

Pitfall: prefer Debian or Ubuntu images to Alpine. Alpine uses a different C library (musl), and the Phoenix team recommends against it because of DNS resolution failures and trouble with dynamically linked NIFs and precompiled binaries.

Pitfall: pin exact image tags (Elixir, OTP and the Debian date) and use the same Debian release in both stages. A floating tag means the release you tested is not the one you ship.

Pitfall: never bake secrets into an image: anyone who can pull it can read them. The image stays the same everywhere; secrets arrive as environment variables or mounted files at docker run time and are read in config/runtime.exs (see Runtime Configuration).

Pitfall: the release script becomes the container's main process. Platforms that don't reap zombie processes may need a small init such as tini as the entry point, as the Phoenix Dockerfile notes.