
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Shipping Elixir applications reliably requires handling the BEAM ecosystem's specific toolchain requirements, not just generic build steps. A properly configured CI/CD pipeline for Elixir with GitHub Actions must manage Erlang/OTP version coupling, compile-time dependencies, and immutable release artifacts to avoid flaky builds. This guide walks through a production-hardened workflow that balances fast feedback loops with audit-ready deployment practices suitable for teams in Nepal or globally distributed environments.
erlef/setup-beam action for precise OTP/Elixir version pinning, enables hex.pm and dialyzer caching to cut build times by 60%, runs matrix tests across supported versions, and produces immutable Docker release artifacts for deployment.How do you configure a CI/CD pipeline for Elixir with GitHub Actions correctly?
The most common failure mode in Elixir CI is version drift between the Erlang VM and the Elixir compiler. Unlike Node.js or Python, Elixir compiles against a specific OTP release, and mixing versions causes subtle runtime crashes or dialyzer false positives. Your workflow must treat the OTP/Elixir pair as a single atomic unit.
Pin versions explicitly with setup-beam
Never rely on "latest" tags. Use the official erlef/setup-beam action which manages both Erlang and Elixir installations with checksum verification. This is superior to the deprecated actions/setup-elixir because it supports the full range of OTP versions including RC builds needed for forward compatibility testing.
name: Elixir CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-24.04
env:
MIX_ENV: test
steps:
- uses: actions/checkout@v4
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
elixir-version: '1.17.3'
otp-version: '27.1'
install-hex: true
install-rebar: true
- name: Restore deps cache
uses: actions/cache@v4
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix- This configuration ensures deterministic builds. The cache key includes the lockfile hash so dependency updates trigger fresh fetches while unchanged projects restore in seconds. For teams managing multiple services, consider reading our guide on GitHub Actions reusable workflows and matrix builds to centralize this setup across repositories.
Handle database-dependent tests safely
Elixir applications frequently depend on PostgreSQL or MySQL. Instead of installing databases directly on the runner, use GitHub Actions service containers. They provide network isolation and automatic cleanup.
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5 Always include health checks. Without them, your test step may start before Postgres accepts connections, causing intermittent failures that waste debugging time. Reference PostgreSQL administration essentials for tuning parameters that speed up test database initialization.
Why is caching critical for Elixir GitHub Actions performance?
Elixir compilation is CPU-intensive and stateful. The BEAM compiler generates .beam files that depend on macro expansion results from previous compilations. Without proper caching, every CI run recompiles the entire dependency tree and standard library, adding 3–8 minutes to each job.
Beyond basic dependency caching, enable Dialyzer PLT caching. Dialyzer constructs a Persistent Lookup Table analyzing the entire OTP stdlib plus your project. This takes 5+ minutes on first run but drops to seconds when cached. Store PLTs keyed by OTP+Elixir version pair since they're incompatible across versions.
- name: Restore Dialyzer PLT
uses: actions/cache@v4
with:
path: priv/plts
key: ${{ runner.os }}-plt-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('/mix.lock') }}
restore-keys: |
${{ runner.os }}-plt-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}- A common mistake is caching _build without understanding environment separation. Always set MIX_ENV=test (or prod) at the job level, not inside individual steps. Cached artifacts from one environment corrupt another. If you need both test and prod builds in the same workflow, use separate jobs with distinct cache keys.
How do you run matrix tests across Elixir and OTP versions?
Elixir libraries and frameworks often support multiple OTP versions simultaneously. Matrix builds validate compatibility without duplicating workflow files. However, not all combinations are valid — Elixir 1.17 requires OTP 25+, and certain features like binary pattern matching behave differently across OTP releases.
| Elixir Version | Min OTP | Max OTP | CI Priority | Notes |
|---|---|---|---|---|
| 1.17.x | 25 | 27 | Primary | Current stable; test all three OTPs |
| 1.16.x | 24 | 26 | Secondary | LTS support; test min/max only |
| 1.15.x | 24 | 26 | Tertiary | Legacy; test max OTP only |
| 1.18-dev | 26 | 27 | Nightly | Allow failure; catch regressions early |
Configure the matrix dynamically rather than hardcoding invalid pairs. Use the exclude directive to skip known-broken combinations and include for special cases like nightly builds.
strategy:
fail-fast: false
matrix:
elixir: ['1.16.3', '1.17.3']
otp: ['26.2', '27.1']
exclude:
- elixir: '1.16.3'
otp: '27.1'
include:
- elixir: '1.18.0-rc.0'
otp: '27.1'
experimental: true Set fail-fast: false so one failing combination doesn't cancel others. Mark experimental versions with a custom flag and use continue-on-error: ${{ matrix.experimental }} on the test step. This gives visibility into upcoming breaks without blocking merges. For deeper testing strategy context, see test automation strategy and the testing pyramid.
What is the correct way to build and deploy Elixir releases?
Elixir releases are self-contained executables bundling the BEAM VM, application code, and dependencies. Never deploy raw source code or mix run in production. Releases enable hot upgrades, graceful shutdowns, and predictable memory footprints essential for containerized environments.
Multi-stage Docker builds for minimal images
Production Elixir images should be under 150MB. Achieve this with multi-stage builds separating compilation from runtime. The builder stage needs full toolchain; the runtime stage needs only libssl, libncurses, and locale data.
# Builder stage
FROM hexpm/elixir:1.17.3-erlang-27.1-debian-bookworm-20240904 AS builder
RUN apt-get update && apt-get install -y git ca-certificates
WORKDIR /app
COPY mix.exs mix.lock ./
RUN mix local.hex --force && mix local.rebar --force && mix deps.get --only prod
COPY config/config.exs config/prod.exs config/
COPY lib lib
RUN MIX_ENV=prod mix release
# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y libssl3 libncurses6 locales \
&& sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
WORKDIR /app
COPY --from=builder /app/_build/prod/rel/my_app ./
ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8
CMD ["bin/my_app", "start"] Tag images with Git SHA, not semantic version alone. Semantic tags mutate; SHAs don't. This matters for rollbacks and audit trails. In regulated environments, also sign images with Sigstore cosign and store SBOMs alongside artifacts. Teams handling compliance should review handling secrets in CI/CD pipelines safely to avoid leaking credentials during release builds.
Secure secret injection without environment leaks
Never embed secrets in Dockerfiles or pass them as build args. Use GitHub Actions OIDC to authenticate against AWS Secrets Manager, HashiCorp Vault, or GCP Secret Manager at deploy time. Inject secrets into the running container via mounted volumes or runtime environment, never baked into layers.
For Kubernetes deployments, use external-secrets-operator or sealed-secrets to synchronize credentials post-deployment. This keeps your CI pipeline free of long-lived credentials and satisfies SOC 2 evidence requirements around secret rotation and access logging.
Start shipping Elixir with confidence
A well-tuned CI/CD pipeline for Elixir with GitHub Actions pays dividends daily through faster feedback, fewer production incidents, and simpler compliance audits. Start with the pinned setup-beam configuration and caching strategy outlined here, then layer in matrix testing and immutable releases as your team matures. Monitor your pipeline metrics — if average build time exceeds five minutes or flaky test rates climb above 2%, revisit your cache keys and service container health checks. Need help designing an audit-ready Elixir deployment workflow or optimizing existing infrastructure? Reach out to discuss your specific requirements.