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-formattedneeds no compile; run it first, it fails fast.mix credo --strictis 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 andmix.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.
Related
- Add a CI Speed Badge to Your READMEShow how fast your CI is with a README badge: the median GitHub Actions run time for your repository, updated automatically. Free for public repos.
- Speed Up .NET in GitHub ActionsSpeed up .NET builds in GitHub Actions: NuGet caching with setup-dotnet, --no-restore, --no-build, parallel dotnet test, and SQL Server services.
- Speed Up Bazel in GitHub ActionsSpeed up Bazel in GitHub Actions with setup-bazel, a disk cache, repository cache, a remote cache, and the right --jobs setting. Working YAML.
- Speed Up Django in GitHub ActionsSpeed up Django tests in GitHub Actions with pip or uv caching, pytest-xdist, --reuse-db, a Postgres service, and test splitting. Working YAML.