Skip to content
compiler.dev

Speed Up Elixir in GitHub Actions

Last updated

To speed up Elixir in GitHub Actions, install Erlang and Elixir with erlef/setup-beam, cache deps and _build with a key built from mix.lock and the OTP and Elixir versions, and split tests with mix test --partitions. Elixir compiles fast, but a cold build compiles every dependency, including C-based ones like bcrypt_elixir, and that is where most wasted time sits.

This guide shows a complete Phoenix workflow. For the general approach, read how to speed up GitHub Actions.

Install Erlang and Elixir

erlef/setup-beam is the community-standard action. Per its README, you set otp-version and elixir-version as strings, or use version-file to read a .tool-versions file (files ending in .toml are parsed as mise config). If you use a version file, the README says version-type must be strict. Do not mix a version file with the YAML inputs; the action exits with an error. The action's README recommends the moving v1 tag, or an exact tag such as v1.8.0 if you want to pin.

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

The README lists otp-version and elixir-version as outputs, which hold the exact versions installed. Use them in your cache key.

Cache deps and _build

The README does not include a caching recipe, so build one. Two folders matter: deps (fetched dependencies) and _build (compiled output). The key must change when the lockfile or either runtime version changes, since _build is not portable across OTP versions.

- uses: actions/cache@v6
  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 compile --warnings-as-errors

The restore-keys fallback means a changed mix.lock starts from the previous _build, and Mix recompiles only the dependencies that changed. Running mix deps.get after the cache restore is safe and fast; it only fetches what is missing. Keep MIX_ENV consistent: _build/test and _build/dev are separate folders, and CI should compile test only.

env:
  MIX_ENV: test

Cache entries are immutable and have size and time limits; see GitHub Actions cache limits. If _build grows large over time, include a version prefix (mix-v2-) in the key so you can reset it.

Use a Postgres service

Phoenix apps with Ecto need a database. Add a service with a health check:

services:
  postgres:
    image: postgres:17
    env:
      POSTGRES_PASSWORD: postgres
    ports: ["5432:5432"]
    options: >-
      --health-cmd "pg_isready -U postgres"
      --health-interval 5s --health-timeout 5s --health-retries 10

Point config/test.exs at localhost and read the host from an environment variable so it works locally and in CI. Ecto's SQL sandbox lets tests run concurrently, each in its own transaction, so use use MyApp.DataCase, async: true wherever tests do not share global state.

Split tests: concurrency, then partitions

Elixir has two layers of test parallelism.

Inside one machine: ExUnit runs async: true test modules concurrently. The default max_cases is twice the number of schedulers, so more cores means more tests at once. Convert as many modules to async: true as you can. Tests that touch the filesystem, named processes, global config or Application.put_env should stay synchronous.

Across machines: mix test --partitions N splits test files into N partitions and runs one, selected by the MIX_TEST_PARTITION environment variable. Use a matrix:

strategy:
  fail-fast: false
  matrix:
    partition: [1, 2, 3, 4]
steps:
  # ...checkout, setup-beam, cache, deps...
  - run: mix test --partitions 4
    env:
      MIX_TEST_PARTITION: ${{ matrix.partition }}

With partitions, Ecto's docs suggest putting the partition number in the database name, so each job's database is separate: in config/test.exs, use database: "myapp_test#{System.get_env("MIX_TEST_PARTITION")}". That is needed when partitions share one database server, such as when you run several partitions on the same machine. For separate jobs with separate service containers it is harmless but not required. See test sharding in GitHub Actions and the test shard calculator.

Lint, formats and Dialyzer in separate jobs

  • mix format --check-formatted needs no compile; run it first, it fails fast.
  • mix credo --strict is fast.
  • Dialyzer (via Dialyxir) is slow because it builds a PLT (persistent lookup table) of Erlang and Elixir modules. Cache the PLT folder, for example priv/plts, with a key on OTP version, Elixir version and mix.lock. A warm PLT turns a ten-minute run into a short one. Keep it in its own job so it never blocks tests.

Releases and Docker

If you build a release image, compile deps in an earlier Docker layer than your app code, and use BuildKit cache. See Docker layer caching in CI.

What more CPU cores buy you

The BEAM uses every core for concurrent tests, and mix compile compiles files in parallel. Our benchmarks include Phoenix LiveView + Postgres: 2m07s on a GitHub-hosted runner and 1m03s on compiler.dev (2.0x), a full CI run with 10 test shards and a real database, measured 8 October 2026. Async-heavy suites get the most benefit, since max_cases scales with the machine.

To test yours, change one line to runs-on: [self-hosted, Linux, compiler-ci]. See pricing and compare. Related: the Elixir template and GitHub Actions caching.

FAQ

What should the Elixir cache key contain?

The runner OS, OTP version, Elixir version and a hash of mix.lock. _build is not safe to reuse across OTP versions.

Does mix test --partitions work with Ecto?

Yes. Use the MIX_TEST_PARTITION variable to make the database name unique per partition when partitions share a database server.

Should I cache _build or just deps?

Both. Caching only deps saves downloads, but caching _build saves the compile time, which is usually larger.

How do I speed up Dialyzer in CI?

Cache the PLT directory with a key on OTP version, Elixir version and mix.lock, and run it as its own job.

Run this workflow on compiler.dev 200 free minutes

Start free