Dockerize a Elixir App with Multi-Stage Builds

Khimananda Oli 8 min read Programming and Languages
Dockerize a Elixir App with Multi-Stage Builds

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.

Builder Stageelixir:1.17-alpinemix deps.getmix assets.deploymix release~1.2 GB layerRelease Artifact_build/prod/rel/BEAM bytecodeCompiled assetsConfig & scripts~45 MB portableRuntime Stagedistroless/cc-debian12COPY --from=builderNo shell / apt / gccNon-root user~85 MB finalMulti-stage isolation: build tools never reach production
Three-stage Docker build isolates compilation from runtime when you Dockerize a Elixir app with multi-stage builds

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 ApproachTypical SizeCold Start (EKS)Security SurfaceCache Efficiency
Single-stage alpine450–700 MB18–25sFull OS + toolchainPoor (no layer separation)
Multi-stage slim120–180 MB6–10sMinimal OS libsGood
Multi-stage distroless75–110 MB3–6sNo shell/pkg mgrExcellent
Naive COPY . .900–1.4 GB30–45sEverything exposedNone

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.

Source Codelib/ config/ priv/mix deps.compileHex packages → BEAMassets.deployesbuild + tailwindmix releaseSelf-contained rel/Release Contents (_build/prod/rel/my_app/)bin/my_app (boot script)erts-15.0/ (OTP runtime)lib/ (compiled .beam)priv/ (static assets)All dependencies embedded — no external Hex or npm required at runtimeRuntime Container (distroless)Only release dir + libc/glibc — no shell, no apt, no node, no gitAttack surface minimized • Image ~85 MB • Starts in <2 seconds
Elixir release pipeline inside Docker builder produces portable artifact copied to distroless runtime

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 set ENV MIX_ENV=prod before any mix command.
  • Missing system libraries: Packages like bcrypt_elixir, image, or exqlite require C extensions. Install their build deps (build-essential, libpng-dev) in the builder stage, not runtime.
  • Secrets baked into layers: Never COPY .env or embed API keys. Use runtime environment variables or a secrets manager. Each Docker layer is permanent; deleted files remain accessible via docker history.
  • Wrong USER directive placement: Setting USER nonroot before COPY causes permission errors. Place it after all filesystem operations, just before CMD.
  • Ignoring .dockerignore: Without it, COPY . . includes _build/, deps/, .git/, and node_modules/, invalidating cache and inflating context. Add these paths explicitly.
  • Hardcoded ports: Cloud platforms assign dynamic ports. Read PORT from 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:

  1. Base image tag (changes monthly)
  2. System packages (changes rarely)
  3. mix.exs + mix.lock (changes weekly)
  4. mix deps.get + mix deps.compile (cached if lock unchanged)
  5. Asset source files (changes frequently)
  6. Application source (changes most frequently)
  7. 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.

Image Size Comparison (MB)0350700105014001,280 MBNaive COPY580 MBAlpine Single145 MBSlim Multi85 MBDistrolessDockerize a Elixir app with multi-stage builds + distroless = 93% size reduction
Image size comparison demonstrates why multi-stage distroless builds dominate for Elixir production deployments

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.

Frequently Asked Questions

Multi-stage builds separate compilation dependencies from runtime, producing final images under 100MB. This reduces attack surface, speeds up deployments, and eliminates build tools like gcc from production containers while keeping full functionality intact.

Use hexpm/elixir-base or official elixir:1.17-alpine for the runtime stage. The builder stage can use elixir:1.17-slim to include necessary build dependencies without bloating the final artifact with development packages.

Copy mix.exs and mix.lock first, run mix deps.get and mix deps.compile, then copy source code. This layering ensures dependency installation only reruns when lock files change, saving minutes per build.

Yes, compile Tailwind, esbuild, or Phoenix assets in the builder stage using Node.js alpine. Copy only the generated priv/static output to the runtime stage to avoid including node_modules in production images.

Final images drop from 800MB to roughly 70-90MB by excluding Erlang compiler, build tools, and dev dependencies. This 90% reduction improves CI pipeline speed and cold start times significantly.

Never bake secrets into images. Use runtime configuration via RELEASE_SECRET_KEY_BASE and DATABASE_URL environment variables. Configure releases.exs to read system vars at boot time rather than compile time.

No. Debug stages are discarded after build. Create a separate docker-compose.debug.yml with single-stage builds and observer_cli included for local troubleshooting without affecting production image optimization.

Initial builds take longer due to multiple stages, but subsequent builds are faster with proper layer caching. Configure BuildKit inline cache metadata to persist intermediate layers across CI runs effectively.

Run docker history and dive to inspect layers. Check that gcc, make, python, and node binaries are absent. Only beam.smp, release scripts, and compiled .beam files should exist in runtime stage.

Create a non-root user named nobody or app with UID 65534. Set USER directive before CMD and ensure release directories are owned by this user to prevent privilege escalation vulnerabilities.

Include eval "MyApp.Release.migrate" in your release start script or run as an init container. Never execute migrations during image build since database connectivity is unavailable in isolated build environments.

No. Native Elixir releases have been stable since 1.9 and require no external dependencies. Distillery adds unnecessary complexity to multi-stage builds and receives minimal maintenance compared to built-in tooling.

Combine RUN commands with && operators for related operations. Merge chown, chmod, and cleanup steps into single instructions to minimize filesystem layers and improve image pull performance.

Expose /health at port 4000 returning 200 OK. Configure HEALTHCHECK in Dockerfile with 30s interval and 5s timeout. Ensure endpoint does not depend on external services to avoid false negatives.

Use ARG for version pins and non-sensitive metadata only. Never pass credentials as build args since they persist in image history. Inject secrets exclusively through runtime environment variables or mounted volumes.