Elixir and Phoenix Builds on GitHub Actions

Elixir builds on GitHub Actions stall on cold deps, _build recompiles, and Dialyzer PLT rebuilds. Cache each separately on a warp- runner and size for mix test.

Last verified:

Elixir builds on GitHub Actions spend most of their wall clock in three places: fetching and compiling deps/, rebuilding _build/ after an OTP or Elixir version change, and rebuilding the Dialyzer PLT from scratch. The fix is to cache deps/ and _build/ as separate entries keyed on the OTP version, the Elixir version, and mix.lock, give the PLT its own cached directory, and run the job on a warp- runner sized for the concurrency mix test actually uses.

Overview

A Mix project keeps its state in two directories that behave differently. deps/ holds fetched source for every dependency and changes only when mix.lock changes. _build/<env>/ holds compiled BEAM files for those dependencies plus your own application, and it is invalidated by a change to the Elixir version, the OTP version, or the compiler options, in addition to any dependency change.

Most slow Elixir jobs treat those two directories as one cache entry, or skip _build/ entirely. Both choices force a full mix deps.compile on every run, which for a Phoenix application with 60 to 100 dependencies is the largest single step in the job.

WarpBuild runners register against your organization under warp- labels, and the Linux runner images carry the same tooling as GitHub-hosted runners, so erlef/setup-beam, mix, and dialyxir run unchanged. Switching a job means editing the runs-on line.

The rest of this page covers a working workflow with split caches, the PLT configuration that keeps Dialyzer warm, sizing guidance from 4 vCPUs to 16 vCPUs with the list-price arithmetic, and the three bottlenecks that dominate Elixir and Phoenix jobs. For the general case across every language, the checklist for speeding up GitHub Actions is the broader tour.

Configuration

The workflow below runs a Phoenix test suite on warp-ubuntu-latest-x64-8x. It restores deps/ and _build/test through two separate WarpBuilds/cache entries, each keyed on the OTP version, the Elixir version, and the hash of mix.lock.

name: elixir-ci

on:
  push:
    branches: [main]
  pull_request:

env:
  MIX_ENV: test

jobs:
  test:
    runs-on: warp-ubuntu-latest-x64-8x
    strategy:
      matrix:
        include:
          - otp: "27.3"
            elixir: "1.18.3"
    steps:
      - uses: actions/checkout@v4

      - uses: erlef/setup-beam@v1
        with:
          otp-version: ${{ matrix.otp }}
          elixir-version: ${{ matrix.elixir }}

      - name: Restore deps
        uses: WarpBuilds/cache@v1
        with:
          path: deps
          key: deps-otp${{ matrix.otp }}-ex${{ matrix.elixir }}-${{ hashFiles('mix.lock') }}
          restore-keys: |
            deps-otp${{ matrix.otp }}-ex${{ matrix.elixir }}-

      - name: Restore compiled build tree
        uses: WarpBuilds/cache@v1
        with:
          path: _build/test
          key: build-otp${{ matrix.otp }}-ex${{ matrix.elixir }}-${{ hashFiles('mix.lock') }}
          restore-keys: |
            build-otp${{ matrix.otp }}-ex${{ matrix.elixir }}-

      - name: Fetch and compile dependencies
        run: |
          mix deps.get
          mix deps.compile

      - name: Drop restored artifacts for this application
        run: rm -rf _build/test/lib/my_app

      - name: Compile
        run: mix compile --warnings-as-errors

      - name: Run tests
        run: mix test

Two details in that file carry most of the value.

The caches are split because the cache version is a hash over the compression tool and the list of path entries. A single entry covering deps and _build/test gets one version and one key, so any change that invalidates one directory throws away the other. Split entries let deps/ hit exactly on mix.lock while _build/test falls back through restore-keys to the previous compile, so a one-line dependency bump recompiles one dependency rather than all of them.

The rm -rf _build/test/lib/my_app step exists because a restored _build/ tree makes the compiler treat your own modules as already built. That suppresses warnings that --warnings-as-errors is supposed to catch and hides stale artifacts after a refactor. Deleting only your own application directory keeps every dependency artifact warm and costs a few seconds of recompilation.

WarpBuilds/cache is a drop-in replacement for actions/cache@v4, so path, key, restore-keys, and fail-on-cache-miss behave the same way. Entries are scoped to key, version, and branch, and an entry expires 7 days after its last use. The split restore and save variants used below are documented on the caching page.

Keeping the Dialyzer PLT warm

Dialyzer needs a persistent lookup table built from the OTP applications and from your dependencies. By default dialyxir writes it inside _build/, where it is invalidated by the same events that invalidate compiled artifacts. Move it out first, in mix.exs:

def project do
  [
    app: :my_app,
    dialyzer: [
      plt_local_path: "priv/plts",
      plt_core_path: "priv/plts"
    ]
  ]
end

Then cache priv/plts with the split restore and save actions, so the expensive build runs only on a miss:

  dialyzer:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4

      - uses: erlef/setup-beam@v1
        with:
          otp-version: "27.3"
          elixir-version: "1.18.3"

      - name: Restore PLT
        id: plt
        uses: WarpBuilds/cache/restore@v1
        with:
          path: priv/plts
          key: plt-otp27.3-ex1.18.3-${{ hashFiles('mix.lock') }}
          restore-keys: |
            plt-otp27.3-ex1.18.3-

      - name: Build PLT
        if: steps.plt.outputs.cache-hit != 'true'
        run: |
          mix deps.get
          mix dialyzer --plt

      - name: Save PLT
        if: steps.plt.outputs.cache-hit != 'true'
        uses: WarpBuilds/cache/save@v1
        with:
          path: priv/plts
          key: ${{ steps.plt.outputs.cache-primary-key }}

      - name: Run Dialyzer
        run: mix dialyzer --format github

The OTP version belongs in the PLT key because the core PLT is built from the OTP applications shipped with that release. Bumping OTP without changing the key produces a PLT that Dialyzer rejects and rebuilds anyway, so you pay the rebuild without noticing why.

Sizing

mix test runs cases tagged async: true concurrently, with max_cases defaulting to System.schedulers_online() times two. On a runner with 8 vCPUs that is 16 concurrent cases. The compiler is also parallel across scheduler threads, so a large module graph compiles faster on a wider machine. Runner size therefore sets both compile throughput and test concurrency.

These are the Linux sizes worth considering, with rates as listed on the pricing page:

Runner labelvCPURAMStoragePrice per minute
warp-ubuntu-latest-x64-2x28GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32128GB150GB SSD$0.064
warp-ubuntu-latest-arm64-8x832GB150GB SSD$0.012
warp-ubuntu-latest-arm64-16x1664GB150GB SSD$0.024

4x for a single application with async tests. Eight concurrent cases, 16GB of RAM. This covers a library or a small service where the suite is mostly unit tests and the database fixture set is light.

8x for a Phoenix application with database-backed tests. Sixteen concurrent cases against a Postgres service container, each holding a checked-out sandbox connection. 32GB of RAM is what keeps sixteen Ecto sandboxes plus the BEAM heap comfortable. Most Phoenix teams land here.

16x for umbrella projects. An umbrella multiplies both halves of the work: the compile graph spans every child application, and the test count is the sum across children. Thirty-two concurrent cases on 64GB is the size where a large umbrella stops queueing behind itself. Before buying it, check whether your async: true coverage is high enough to use the concurrency, since synchronous cases run one at a time no matter how many schedulers exist.

ARM64 rows carry lower per-minute rates at the same shape. The BEAM and the common native dependencies build cleanly on ARM64, so an Elixir suite is a good candidate for the switch.

List-price arithmetic

GitHub per-minute prices below come from the GitHub Actions billing reference and the shapes from the GitHub-hosted runner specifications, both checked on 2026-08-13. WarpBuild rates come from the table above.

WarpBuild runnerShapeWarpBuild per minuteGitHub-hosted equivalentGitHub per minuteLower list price
warp-ubuntu-latest-x64-2x2 vCPU, 8 GB$0.004ubuntu-latest on private repositories$0.00633 percent
warp-ubuntu-latest-x64-4x4 vCPU, 16 GB$0.0084-core Linux larger runner$0.01233 percent
warp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.0168-core Linux larger runner$0.02227 percent
warp-ubuntu-latest-x64-16x16 vCPU, 64 GB$0.03216-core Linux larger runner$0.04224 percent
warp-ubuntu-latest-x64-32x32 vCPU, 128 GB$0.06432-core Linux larger runner$0.08222 percent

Public repositories get a 4 vCPU, 16GB GitHub-hosted shape at no charge, so the arithmetic above matters for private repositories, which is where most Phoenix applications live.

Now put wall clock against it. Assume a Phoenix suite that takes 14 minutes on GitHub's standard 2 vCPU hosted runner and runs 600 times a month, and assume your own measurement puts the same suite at 5 minutes on 8 vCPUs with warm caches. Substitute your real numbers, because parallel efficiency depends on how much of the suite is tagged async: true.

SetupWall clockRate per minuteCost per runCost per month (600 runs)
GitHub-hosted, 2 vCPU14 min$0.006$0.084$50.40
GitHub-hosted 8-core larger runner5 min$0.022$0.110$66.00
warp-ubuntu-latest-x64-8x5 min$0.016$0.080$48.00

Buying vCPUs from GitHub raises the monthly bill even though the suite finishes sooner, because the rate climbs faster than the wall clock falls. At the same 8 vCPU shape the WarpBuild list price is lower, so the shorter suite and the smaller bill stop trading against each other.

Bottlenecks

Three bottlenecks account for most slow Elixir and Phoenix jobs on GitHub Actions.

Recompiling dependencies on OTP and Elixir version churn

BEAM artifacts in _build/ are tied to the Elixir and OTP releases that produced them. A matrix that spans two OTP versions and two Elixir versions has four distinct _build/ trees, and a workflow keyed without those versions will thrash: each matrix leg overwrites the previous leg's entry and every leg starts cold.

The fix is the key structure in the workflow above. Put both versions in the cache key so each matrix leg owns its own entry, and let restore-keys fall back within the same leg. When you do bump OTP deliberately, expect one full recompile per leg and then steady warm runs.

Storage for this is cheap and priced openly: cache storage is $0.20 per GB-month and each cache write or restore is $0.0001. A 1.5GB combined deps/ and _build/ cache therefore costs about $0.30 a month to keep warm, and entries expire 7 days after last use.

Dialyzer PLT builds

A PLT built from OTP plus a Phoenix dependency tree is hundreds of megabytes and takes minutes of pure CPU work. The cached configuration above removes the rebuild on the common path, but the restore path still costs a download and a zstd decompression of a large file on every job.

This is where snapshot runners change the shape of the problem. A snapshot captures the runner VM mid-workflow and later jobs boot from that captured disk, so the PLT is already present at boot with no restore step and no rebuild. Build the PLT once on main behind snapshot.enabled=true, save it with WarpBuilds/snapshot-save, and point pull request jobs at snapshot.key=<alias>:

jobs:
  dialyzer:
    runs-on: >-
      ${{ github.ref == 'refs/heads/main'
        && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
        || 'warp-ubuntu-latest-x64-8x;snapshot.key=elixir-plt' }}

Three constraints decide whether that trade is worth taking. Snapshot boot takes 45 to 60 seconds, which is slower than a default runner boot, so the PLT restore you avoid has to cost more than that. Snapshots are deleted after 15 days, and a snapshot held for its full lifetime costs 15 times 24 times $0.025, which is $9.00 in snapshot storage, with $0.04 added per job that restores from it. And snapshot runners are supported on WarpBuild Cloud Ubuntu runners only: snapshot labels on BYOC, Windows, or macOS runners are silently ignored and the job runs normally.

The rule of thumb: a cache is the right tool while the PLT restore stays under roughly a minute, and a snapshot wins once the PLT grows large enough that restoring and decompressing it dominates the Dialyzer job. Details on both are in the snapshot runners documentation and on the snapshot runner page.

The Phoenix asset pipeline

mix assets.deploy runs esbuild, tailwind, and phx.digest. The Elixir esbuild and tailwind packages download a platform binary on first use and write it under _build/, so a job with no _build/ cache pays two binary downloads before any asset work begins. Caching _build/<env> as shown above covers them.

Two further details are worth checking. If your assets pipeline shells out to npm, cache assets/node_modules as its own entry keyed on assets/package-lock.json, for the same reason deps/ and _build/ are split. And run mix phx.digest once in the release job rather than in every test job, since digesting fingerprints and gzips the whole static tree and contributes nothing to test results.

When a failure reproduces only inside GitHub Actions, the WarpBuild Action Debugger pauses the workflow and opens an SSH session on the live runner, which is faster than adding IO.inspect calls and re-running a 14 minute suite. WarpBuild's CI observability reports system metrics from the runner agent correlated with the job logs, so you can check whether 16 concurrent cases actually saturate an 8x runner before paying for a 16x. Both are covered on the incremental builds guide alongside the caching patterns.

Proof

Every rate on this page is on the pricing page.

Several of them run large test matrices across multiple language runtimes on the same runner fleet described here.

SSO is available for a flat $250 per month, whatever the user count.

Teams running Rails alongside Phoenix hit the same split-cache and sizing questions in a different vocabulary; the Ruby and Rails on GitHub Actions page covers that ecosystem.

FAQ

How should I cache deps and _build for an Elixir project?

Cache them as two separate entries. The cache version is a hash over the compression tool and the list of cached paths, so one entry covering both directories invalidates as a unit. Key both on the OTP version, the Elixir version, and the hash of mix.lock.

Why does my Dialyzer PLT rebuild on every GitHub Actions run?

The default PLT path sits inside _build, which most workflows either skip caching or invalidate on every dependency change. Move it with plt_local_path and plt_core_path in mix.exs, cache that directory on its own key, and include the OTP version in the key.

What runner size should I use for mix test?

ExUnit runs async cases with max_cases defaulting to System.schedulers_online() times two, so vCPU count sets concurrency directly. Start at warp-ubuntu-latest-x64-4x for a single app, 8x for a Phoenix app with database-backed tests, and 16x for umbrella projects.

Do snapshot runners work for Elixir builds on BYOC or macOS?

No. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only. Snapshot labels applied to BYOC, Windows, or macOS runners are silently ignored and the job runs normally without snapshot functionality.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.