CI/CD Pipeline for Elixir with GitHub Actions

Khimananda Oli 8 min read Programming and Languages
CI/CD Pipeline for Elixir with GitHub Actions

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.

Git Pushmain / PRLint & Formatcredo + sobelowMatrix TestOTP 26/27 + ElixirBuild ReleaseDocker + SHA tagDeployK8s / Fly
High-level CI/CD pipeline for Elixir with GitHub Actions: sequential gates from push to immutable deployment artifact.

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.

mix.lock Changed?Hash comparisonNoYesRestore Cache Hitdeps/ + _build/ restored~15 secondsCache Miss / Partialmix deps.get + compile~4-6 minutesRun TestsIncremental compileOnly changed modules
Cache invalidation logic: mix.lock hash determines whether dependencies are restored or rebuilt, directly impacting CI duration.

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 VersionMin OTPMax OTPCI PriorityNotes
1.17.x2527PrimaryCurrent stable; test all three OTPs
1.16.x2426SecondaryLTS support; test min/max only
1.15.x2426TertiaryLegacy; test max OTP only
1.18-dev2627NightlyAllow 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.

Naive Pipeline• No caching: 8 min compile• Single OTP version tested• 1.2GB Docker image• Mutable :latest tags• Secrets in ENV varsTotal: ~18 min | High riskOptimized Pipeline• Hex+PLT cache: 45s restore• Matrix: OTP 26+27 parallel• Multi-stage: 85MB image• Immutable SHA tags + cosign• OIDC + Vault secretsTotal: ~4 min | Audit-ready78% Faster Feedback93% Smaller Attack Surface
Before/after comparison: optimized CI/CD pipeline for Elixir with GitHub Actions reduces cycle time and improves security posture dramatically.

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.

Frequently Asked Questions

Use actions/cache with the mix.lock hash as the key to store deps and _build directories. This prevents redownloading Hex packages on every run and reduces pipeline execution time significantly for Elixir projects using GitHub Actions.

Test against OTP 27 and 28 with Elixir 1.17 and 1.18. Use erlef/setup-beam action to install specific version combinations matrix testing ensures compatibility across supported runtime environments in your CI/CD pipeline for Elixir with GitHub Actions.

Yes.

Configure MIX_ENV=test and run mix test --cover in your workflow step. Upload the cover directory as an artifact or integrate with Codecov using their official action to track coverage trends over time in your Elixir CI pipeline.

Yes, specify a container like hexpm/elixir-otp at the job level. This provides preinstalled BEAM tooling without manual setup steps and ensures consistent build environments matching production deployments for reliable Elixir testing in GitHub Actions workflows.

Add a postgres service container with health checks and configure DATABASE_URL via environment variables. Run mix ecto.create and mix ecto.migrate before tests to provision isolated test databases automatically within your Elixir CI/CD pipeline on GitHub Actions runners.

Private repos get 2000 free minutes monthly on standard plans. Linux runners consume one minute per minute used. Monitor usage in billing settings since Elixir compilation can be CPU intensive and may exceed limits during active development cycles requiring paid upgrades.

Use superfly/flyctl-actions after building a release image. Authenticate with FLY_API_TOKEN secret and run flyctl deploy --remote-only. This triggers zero-downtime deployments directly from your Elixir CI/CD pipeline without exposing credentials in workflow logs or artifacts.

Ensure you run mix local.hex --force and mix local.rebar --force before dependency installation. These commands bootstrap package managers non-interactively. Missing this step causes fetch failures because fresh GitHub Actions runners lack preconfigured Hex registry access by default.

Use mix test --partitions N with partition-based sharding across matrix jobs. Each runner executes a subset determined by PARTITION_INDEX environment variable. Combine results afterward to maintain full suite coverage while reducing total wall-clock time for large Elixir test suites.

Yes.

Store credentials as repository or organization secrets never hardcode them. Reference via secrets.CONTEXT_NAME syntax which masks values in logs. Rotate tokens regularly and scope permissions minimally to prevent accidental exposure during Elixir pipeline executions on shared runners.

Cold starts without caching force full recompilation. Enable incremental builds by caching _build directory alongside deps. Also verify MIX_ENV is set correctly since dev environment includes debug info that increases compile times unnecessarily in CI contexts targeting test or prod configurations.

Run mix format --check-formatted and mix credo --strict as separate early-stage jobs. Fail fast on style violations before expensive test runs. Cache PLT files for dialyzer to avoid rebuilding type analysis databases repeatedly across workflow executions in your Elixir CI setup.

Yes, use paths filters in on.push and on.pull_request triggers. Specify lib/, mix.exs, and .github/workflows/ to skip runs when only documentation or unrelated configs change. This conserves minutes and accelerates feedback loops for Elixir developers using GitHub Actions.