Dockerize Elixir Phoenix: Multi-stage Build, OTP Releases, and Production-Ready Erlang Clustering

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

After six months running Phoenix on production in Docker, I realized that deploying Phoenix is fundamentally different from Node.js or Python. Not because it’s harder, but because Phoenix brings an entire OTP ecosystem with it — GenServer, ETS, PubSub, and most importantly Erlang clustering. If you just copy a Dockerfile from a Node.js project, your app will run, but you’re leaving 80% of what Elixir has to offer on the table.

The Problem with Dockerizing Phoenix the Conventional Way

The simplest way to containerize Phoenix — install Elixir, run mix phx.server — produces an image over 1GB packed with build tools, Hex packages, and source code. Every deployment requires a full rebuild taking 5–10 minutes, turning the CI/CD pipeline into a daily bottleneck.

The bigger problem: when you scale to two or more containers, Phoenix PubSub stops working across containers because the Erlang nodes don’t know each other exist. Message broadcasts from container A never reach users connected to container B. LiveView sessions break mid-session. This is when you need to understand OTP Releases and Erlang clustering — not as an advanced feature, but as a prerequisite for Phoenix to work correctly in a distributed environment.

Core Concepts to Understand Before Writing Code

What Are OTP Releases?

Instead of running source code directly, mix release compiles the entire application into a self-contained binary — including the Erlang runtime. The runtime image only needs a Linux base; Elixir and Erlang don’t need to be pre-installed. The practical result: image size drops from 1.2GB to 80–90MB, startup time decreases by 3x, and you get a built-in health check via bin/app eval.

How Does Erlang Clustering Work in Docker?

Erlang nodes discover each other via epmd (Erlang Port Mapper Daemon, port 4369) and then connect directly through a random port range. In Docker, two issues arise:

  • Container name resolution: nodes need to address each other by fully qualified hostname ([email protected])
  • Port visibility: epmd and distribution ports must be accessible between containers on the same network

The cleanest solution is to use the libcluster library with the DNSPoll strategy — automatically discovering nodes via DNS lookup within the Docker internal network, with no hardcoded IPs or hostnames required.

Step-by-Step Walkthrough

Step 1: Configure Mix Release

Declare the release in mix.exs:

def project do
  [
    app: :my_app,
    releases: [
      my_app: [
        include_executables_for: [:unix],
        applications: [runtime_tools: :permanent]
      ]
    ]
  ]
end

Create rel/env.sh.eex to bootstrap clustering from the environment:

#!/bin/sh
export RELEASE_DISTRIBUTION=name
export RELEASE_NODE=<%= @release.name %>@${HOSTNAME}
export RELEASE_COOKIE=${RELEASE_COOKIE:-"change_me_in_production"}

Step 2: Optimized Multi-stage Dockerfile

This is the Dockerfile I’m actively using in production, refined through multiple iterations to optimize layer caching:

# Stage 1: Builder
FROM hexpm/elixir:1.16.3-erlang-26.2.5-debian-bookworm-20240701-slim AS builder

WORKDIR /app

RUN apt-get update -y && apt-get install -y build-essential git \
  && apt-get clean && rm -f /var/lib/apt/lists/*_*

RUN mix local.hex --force && mix local.rebar --force

ENV MIX_ENV=prod

# Cache the deps layer separately
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config

COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile

# Build assets
COPY priv priv
COPY assets assets
RUN mix assets.deploy

# Compile app + build release
COPY lib lib
RUN mix compile
COPY config/runtime.exs config/
COPY rel rel
RUN mix release

# Stage 2: Runtime — keep only what's needed
FROM debian:bookworm-20240701-slim AS runner

WORKDIR /app

RUN apt-get update -y && \
  apt-get install -y libstdc++6 openssl libncurses5 locales curl \
  && apt-get clean && 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 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8

# Run as non-root user
RUN useradd --create-home app
USER app

COPY --from=builder --chown=app:app /app/_build/prod/rel/my_app ./

EXPOSE 4000 4369 9000-9010

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
  CMD bin/my_app eval "MyApp.HealthCheck.check()" || exit 1

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

The mix deps.get and mix deps.compile layers are separated — they only rebuild when mix.lock changes. The first build takes about 4 minutes; subsequent builds take ~45 seconds.

Step 3: Add libcluster for Auto-discovery

Add the dependency to mix.exs:

defp deps do
  [
    {:libcluster, "~> 3.3"},
    # ... other deps
  ]
end

Configure in config/runtime.exs:

config :libcluster,
  topologies: [
    dns_poll: [
      strategy: Cluster.Strategy.DNSPoll,
      config: [
        query: System.get_env("CLUSTER_DNS", "my_app"),
        node_basename: System.get_env("RELEASE_NAME", "my_app"),
        polling_interval: 5_000
      ]
    ]
  ]

Start libcluster in the Application supervisor before other processes:

def start(_type, _args) do
  topologies = Application.get_env(:libcluster, :topologies, [])

  children = [
    {Cluster.Supervisor, [topologies, [name: MyApp.ClusterSupervisor]]},
    MyApp.Repo,
    MyAppWeb.Endpoint
  ]

  Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end

Step 4: Production-Ready Docker Compose with Clustering

I migrated the entire stack from docker-compose v1 to v2 and the transition was quite smooth — simpler syntax, no version: declaration needed, and Docker internal DNS works more reliably with libcluster’s service discovery.

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: my_app_prod
      POSTGRES_USER: my_app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - internal
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U my_app"]
      interval: 10s
      timeout: 5s
      retries: 5

  app:
    image: my_app:latest
    deploy:
      replicas: 2
    environment:
      DATABASE_URL: ecto://my_app:${DB_PASSWORD}@db/my_app_prod
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
      PHX_HOST: myapp.example.com
      PORT: 4000
      RELEASE_COOKIE: ${RELEASE_COOKIE}
      CLUSTER_DNS: app          # libcluster DNS-polls this Docker service name
      RELEASE_NAME: my_app
    ports:
      - "4000"
    networks:
      - internal
      - proxy
    depends_on:
      db:
        condition: service_healthy

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
    networks:
      - proxy
    depends_on:
      - app

volumes:
  db_data:

networks:
  internal:
    internal: true
  proxy:

secrets:
  db_password:
    file: ./secrets/db_password.txt

The key insight: CLUSTER_DNS: app — libcluster DNS-resolves the service name app, and Docker internal DNS automatically returns the IPs of all running replicas. The two nodes find each other with no additional configuration. When scaling to 3 replicas, the third node joins the cluster automatically within 5 seconds.

Step 5: Migration and Deployment Script

Run database migrations safely before routing traffic to the new app:

#!/bin/bash
set -e

docker compose pull
docker compose run --rm app bin/my_app eval "MyApp.Release.migrate()"
docker compose up -d --no-deps app

# Verify the cluster has formed
docker compose exec app bin/my_app rpc "IO.inspect(Node.list())"

The migration module needs to be added to lib/my_app/release.ex:

defmodule MyApp.Release do
  @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

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

The final command in the deploy script prints the list of nodes in the cluster — if you see [:[email protected]], both containers have successfully connected.

Conclusion

This setup takes a few extra hours compared to running mix phx.server directly in a container, but in return you get: an image 10x smaller, automatic clustering, zero-downtime deploys, and a truly production-ready stack. With Elixir, skipping OTP Releases and clustering means skipping the primary reason many teams chose the language in the first place.

After six months running with 2 replicas continuously, this stack handles peak traffic without manual intervention. When a container encounters an issue and restarts, the cluster rebalances itself, PubSub broadcasts continue to work across containers, and users barely notice anything happened.

Share: