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.

