Skip to content
compiler.dev

Elixir GitHub Actions Workflow Template

Last updated

This is a working .github/workflows/ci.yml for an Elixir or Phoenix app with Postgres. It installs Erlang/OTP and Elixir with erlef/setup-beam, caches deps and _build, checks formatting, compiles with warnings as errors and runs mix test.

The workflow

name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    env:
      MIX_ENV: test
      DATABASE_URL: ecto://postgres:postgres@localhost:5432/app_test
    steps:
      - uses: actions/checkout@v4

      - uses: erlef/setup-beam@v1
        id: beam
        with:
          otp-version: "27"
          elixir-version: "1.18"

      - uses: actions/cache@v4
        with:
          path: |
            deps
            _build
          key: mix-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('mix.lock') }}
          restore-keys: |
            mix-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-

      - run: mix deps.get
      - run: mix format --check-formatted
      - run: mix compile --warnings-as-errors
      - run: mix ecto.create && mix ecto.migrate
      - run: mix test

What the non-obvious parts do

  • erlef/setup-beam@v1 installs Erlang/OTP and Elixir. Pin both versions as quoted strings so YAML does not turn them into numbers. The action outputs the exact versions it installed, which the cache key uses through id: beam.
  • Cache key with OTP and Elixir versions. Compiled _build output is only valid for the versions that produced it. Including them prevents loading beam files from a different version. mix.lock changes create a fresh cache, and restore-keys falls back to the latest one for the same versions.
  • deps and _build together. deps holds downloaded packages, _build holds compiled output, including dependencies. Caching both is what makes later runs fast. Compiling dependencies is the slowest part of a cold run. See speed up Elixir on GitHub Actions.
  • MIX_ENV: test at job level makes every step use the test environment, so _build/test is the one cached and compiled.
  • DATABASE_URL assumes config/runtime.exs or config/test.exs reads it. Phoenix generators use ecto:// URLs. If your test config hardcodes the database host, set it to localhost and the user and password above.
  • --warnings-as-errors fails the build on compiler warnings, keeping the codebase clean.
  • Service health check. Without it, ecto.create may run before Postgres accepts connections.

Variants

Version matrix

Add matrix.include pairs such as otp: "26", elixir: "1.17" and otp: "27", elixir: "1.18". Pair versions that are compatible. See matrix builds.

Static analysis

Add mix credo --strict and Dialyzer with dialyxir. Cache the PLT directory (priv/plts) separately, keyed on mix.lock, because building it takes many minutes.

Assets

Phoenix apps with esbuild and Tailwind need mix assets.setup and mix assets.build before tests that render pages.

Test partitions

Mix supports MIX_TEST_PARTITION with --partitions. Combine with test sharding, using a separate database per partition.

Deploy job

Add a second job on main only that builds a release with mix release, uploads it as an artifact, and deploys with OIDC.

Run it on faster machines

The BEAM runs tests concurrently, so extra cores help. Change one line:

    runs-on: [self-hosted, Linux, compiler-ci]

The job runs on a fresh 8 vCPU machine from compiler.dev, with automatic fallback. In compiler.dev's benchmark a Phoenix LiveView app with Postgres went from 2m07s to 1m03s; see benchmarks. Prices are on pricing.

FAQ

Why cache _build as well as deps?

Compiling dependencies is slow, and _build keeps the compiled results.

Can I skip the OTP version in the cache key?

It is risky. Beam files compiled by one OTP version may not load under another.

How do I find compatible OTP and Elixir versions?

The Elixir compatibility table in its official documentation lists supported OTP versions for each release.

Does ecto.create hurt when the database exists?

No. It reports that the database already exists and continues.