
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Elixir applications compile to BEAM bytecode that runs on the Erlang VM, but shipping raw source code into production creates bloated, insecure containers. When you Dockerize a Elixir app with multi-stage builds, you separate compilation dependencies from runtime artifacts, producing lean images that start fast and expose minimal attack surface. This approach is now standard for Phoenix and plain Elixir services targeting Kubernetes or ECS. If you are new to container fundamentals, review Docker for beginners: containerize an app from scratch before proceeding.
How do you configure a multi-stage Dockerfile for Elixir releases?
The key to efficient Elixir containers is understanding that mix release produces a self-contained directory with everything needed to run on a compatible OTP version. Your Dockerfile must respect this boundary. A common mistake is copying the entire project into the runtime stage or installing system packages "just in case." In practice, you need exactly three stages: dependency caching, asset compilation plus release generation, and a clean runtime copy.
Stage 1: Dependency caching layer
Elixir projects fetch hundreds of Hex packages. Without caching, every code change triggers a full re-download. Structure your first stage to isolate mix.exs and mix.lock:
# Stage 1: Fetch and cache dependencies
FROM hexpm/elixir:1.17.2-erlang-27.0.1-debian-bookworm-20240701 AS deps
WORKDIR /app
ENV MIX_ENV=prod
RUN apt-get update -y && \
apt-get install -y git curl && \
rm -rf /var/lib/apt/lists/*
COPY mix.exs mix.lock ./
RUN mix local.hex --force && \
mix local.rebar --force && \
mix deps.get --only prod && \
mix deps.compile Note the specific tag format. The hexpm/elixir images pin exact OTP versions, which matters because BEAM bytecode is not always forward-compatible across major OTP releases. Using elixir:latest will eventually break your builds silently.
Stage 2: Asset compilation and release
Phoenix apps require Node.js for Tailwind/esbuild. Install it temporarily here, compile assets, then generate the release:
# Stage 2: Build assets and create release
FROM deps AS builder
WORKDIR /app
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
COPY . .
RUN mix assets.deploy && \
mix compile && \
mix release The mix assets.deploy task runs esbuild and tailwind pipelines defined in your config/config.exs. It outputs fingerprinted files to priv/static, which the release embeds automatically. Never skip this step for web apps; missing assets cause 404s in production that pass CI.
Stage 3: Minimal runtime
This is where size and security gains materialize. Copy only the release directory:
# Stage 3: Production runtime
FROM gcr.io/distroless/cc-debian12 AS runtime
WORKDIR /app
COPY --from=builder /app/_build/prod/rel/my_app ./
ENV PHX_HOST=localhost \
PHX_PORT=4000 \
PORT=4000 \
RELEASE_COOKIE=secure-random-value
USER nonroot:nonroot
EXPOSE 4000
CMD ["/app/bin/my_app", "start"] Distroless images have no shell, no package manager, and no extraneous binaries. If an attacker compromises your app, they cannot spawn /bin/sh or install tools laterally. For teams needing debugging capability, keep a parallel debug tag in CI but never deploy it.
Why does image size matter for Elixir deployments in 2026?
I have audited dozens of Elixir deployments across AWS EKS and GCP Cloud Run. Teams running 800MB+ images face compounding costs: slower autoscaling (pull time dominates cold starts), higher egress fees, larger node reservations, and longer rollback windows. When you Dockerize a Elixir app with multi-stage builds correctly, images drop to 80–120MB. That difference translates directly to infrastructure savings at scale.
| Build Approach | Typical Size | Cold Start (EKS) | Security Surface | Cache Efficiency |
|---|---|---|---|---|
| Single-stage alpine | 450–700 MB | 18–25s | Full OS + toolchain | Poor (no layer separation) |
| Multi-stage slim | 120–180 MB | 6–10s | Minimal OS libs | Good |
| Multi-stage distroless | 75–110 MB | 3–6s | No shell/pkg mgr | Excellent |
| Naive COPY . . | 900–1.4 GB | 30–45s | Everything exposed | None |
Beyond raw performance, smaller images reduce compliance scope. SOC 2 and ISO 27001 audits examine your software bill of materials. Fewer packages mean fewer CVEs to triage and less evidence to collect. I have seen audit preparation time drop by 40% after teams adopted distroless bases. For deeper context on reducing container footprint generally, see how to reduce Docker image size with multi-stage builds.
What are common pitfalls when Dockerizing Elixir apps?
Even experienced teams stumble on Elixir-specific container issues. These are the failures I encounter most often in production audits:
- MIX_ENV not set in builder: Defaults to
dev, pulling test/dev deps and generating dev releases. Always setENV MIX_ENV=prodbefore anymixcommand. - Missing system libraries: Packages like
bcrypt_elixir,image, orexqliterequire C extensions. Install their build deps (build-essential,libpng-dev) in the builder stage, not runtime. - Secrets baked into layers: Never
COPY .envor embed API keys. Use runtime environment variables or a secrets manager. Each Docker layer is permanent; deleted files remain accessible viadocker history. - Wrong USER directive placement: Setting
USER nonrootbeforeCOPYcauses permission errors. Place it after all filesystem operations, just beforeCMD. - Ignoring .dockerignore: Without it,
COPY . .includes_build/,deps/,.git/, andnode_modules/, invalidating cache and inflating context. Add these paths explicitly. - Hardcoded ports: Cloud platforms assign dynamic ports. Read
PORTfrom env in your endpoint config:http: [port: String.to_integer(System.get_env("PORT") || "4000")].
A particularly subtle issue involves timezone data. Distroless images lack tzdata. If your app uses DateTime.now_utc/1 with zone conversion, add :tzdata to your release configuration or mount timezone files at runtime. Test this explicitly; failures surface only in production.
How do you optimize build cache and CI performance for Elixir containers?
Cache efficiency determines whether your CI takes 2 minutes or 12. Order your Dockerfile instructions from least-frequently-changing to most-frequently-changing. Dependencies change weekly; source code changes hourly. This ordering maximizes cache hits:
- Base image tag (changes monthly)
- System packages (changes rarely)
mix.exs+mix.lock(changes weekly)mix deps.get+mix deps.compile(cached if lock unchanged)- Asset source files (changes frequently)
- Application source (changes most frequently)
mix release(always runs, but fast if deps cached)
In GitHub Actions or GitLab CI, enable BuildKit and layer caching:
DOCKER_BUILDKIT=1 docker build \
--cache-from type=registry,ref=ghcr.io/org/app:cache \
--cache-to type=registry,ref=ghcr.io/org/app:cache,mode=max \
--target runtime \
-t ghcr.io/org/app:${SHA} . The --target runtime flag ensures only the final stage is tagged and pushed. Intermediate stages stay in cache. For teams on limited bandwidth in Nepal or similar regions, this reduces pull times dramatically during deploys. Pair this with build caching strategies for CI to compound gains.
Also consider platform-specific base images. Building on ARM Macs for AMD64 Linux targets requires emulation unless you use docker buildx with native builders. Pin platform explicitly:
FROM --platform=$BUILDPLATFORM hexpm/elixir:1.17.2-erlang-27.0.1-debian-bookworm-20240701 AS deps This avoids accidental cross-compilation failures that manifest as segfaults in production BEAM processes.
Secure and Deploy Your Optimized Elixir Container
When you Dockerize a Elixir app with multi-stage builds, you gain more than smaller images—you establish a security boundary that simplifies compliance, accelerates deployments, and reduces operational toil. Start with the distroless pattern above, validate locally with docker run --rm -p 4000:4000 my_app, then integrate into your CI pipeline with proper caching. Scan every image with Trivy before pushing; even distroless bases receive CVE updates. If your team needs help hardening Elixir deployments for SOC 2 or optimizing Kubernetes resource requests, reach out to discuss your infrastructure.