Cache Elixir Dependencies in CI Pipelines

Khimananda Oli 8 min read Programming and Languages
Cache Elixir Dependencies in CI Pipelines

By Khimananda Oli | Last reviewed: August 2026

Slow feedback loops kill developer productivity, and few things waste more time than watching your CI runner download the same Hex packages repeatedly. When you cache Elixir dependencies in CI pipelines correctly, you shift from network-bound setup phases to near-instant restoration, often cutting total pipeline duration by 40–80%. This guide covers the exact hashing strategies, path configurations, and restoration logic required to make caching reliable across Erlang/OTP and Elixir version matrices.

Why must you cache Elixir dependencies in CI pipelines?

Elixir's build tooling is fast, but fetching and compiling hundreds of transitive Hex dependencies from scratch is not. In my experience managing CI for teams in Nepal and globally, unoptimized Elixir pipelines frequently spend 3–5 minutes just resolving and downloading packages before a single test runs. This latency compounds heavily in matrix builds where you test against multiple OTP/Elixir combinations.

Caching solves this by treating your dependency tree as an immutable artifact tied to your lockfile. However, Elixir presents unique challenges compared to Node.js or Python. The compiled artifacts in _build/ are tightly coupled to the specific Erlang/OTP version used during compilation. A cache created on OTP 27 will crash or produce subtle errors when restored into an OTP 26 environment. Your caching strategy must therefore be version-aware, not just lockfile-aware. For broader context on accelerating automation, see our guide on build caching to speed up CI builds.

Git Checkoutmix.lock + .tool-versionsCache LookupKey: otp-elixir-lock-hash✓ Hit → Restore deps/_build✗ Miss → Fetch & CompileRun Tests / BuildUses cached artifactsPost-Job SaveUpdate cache if changed
Workflow diagram illustrating how to cache Elixir dependencies in CI pipelines with version-aware key generation.

How do you configure GitHub Actions to cache Elixir dependencies?

GitHub Actions remains the dominant CI platform for Elixir projects in 2026. The official actions/cache action works well, but only if you construct keys that account for the BEAM ecosystem's specifics. A common mistake is caching only deps/; you must also cache _build/ to avoid recompiling every dependency after restoration.

Constructing a safe cache key

Your primary key should combine three elements: the OTP version, the Elixir version, and a hash of mix.lock. This ensures that upgrading your runtime or changing a dependency invalidates the old cache automatically. Use restore-keys with a prefix match so that adding a single new package doesn't force a full cold install—it restores the previous state and only fetches the delta.

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

- name: Cache Elixir dependencies in CI pipelines
  uses: actions/cache@v4
  with:
    path: |
      deps
      _build
    key: ${{ runner.os }}-erl${{ matrix.otp }}-ex${{ matrix.elixir }}-mix-${{ hashFiles('**/mix.lock') }}
    restore-keys: |
      ${{ runner.os }}-erl${{ matrix.otp }}-ex${{ matrix.elixir }}-mix-

Note the explicit version variables in the key. If you use .tool-versions or .elixir-version files, hash those instead of hardcoding matrix values. Always exclude _build/prod or _build/test environment-specific subdirectories if you run multiple MIX_ENV targets in the same job, as their beam files can conflict.

Handling umbrella apps and monorepos

For umbrella projects, mix.lock lives at the root, but each child app may have its own compile artifacts. Ensure your path includes the root _build and all child deps directories if they aren't consolidated. In monorepos with multiple independent Elixir apps, scope your cache keys per app directory to prevent cross-contamination between unrelated services.

What paths and keys work best for GitLab CI and other runners?

While GitHub Actions dominates open source, many enterprises and teams in Nepal use GitLab CI for self-hosted compliance reasons. GitLab’s caching mechanism differs fundamentally: it uses fixed keys (or file-based keys via key:files) and uploads/downloads tarballs rather than using content-addressable storage.

variables:
  MIX_ENV: test

.test_cache: &test_cache
  cache:
    - key:
        files:
          - mix.lock
        prefix: erl27-ex1.17
      paths:
        - deps/
        - _build/test/
      policy: pull-push

test:
  image: elixir:1.17-alpine
  <<: *test_cache
  script:
    - mix deps.get --only test
    - mix compile --warnings-as-errors
    - mix test

The prefix field in GitLab serves the same role as version strings in GitHub Actions keys. Without it, switching OTP versions silently restores incompatible binaries. Set policy: pull-push on your main branch jobs to update the cache, and pull on feature branches to avoid race conditions where parallel MRs overwrite each other’s caches. For teams evaluating CI platforms, compare options in our GitHub Actions vs GitLab CI comparison.

GitHub ActionshashFiles('mix.lock')matrix.otp + matrix.elixirrunner.osrestore-keys prefixContent-addressableAutomatic evictionGitLab CIkey:files: [mix.lock]prefix: erl27-ex1.17$CI_COMMIT_REF_SLUGpolicy: pull-pushTarball upload/downloadManual eviction rulesCircleCIchecksum("mix.lock")erlang-version + elixir-verarch + osFallback key templatesImmutable snapshots15-day TTL default
Key component comparison across major CI platforms for Elixir dependency caching strategies.

Which directories should you include or exclude from the cache?

Precision matters. Caching too little defeats the purpose; caching too much introduces stale state bugs that are painful to diagnose. Here is the definitive list for standard Mix projects:

  • Always cache: deps/ (downloaded source packages) and _build/ (compiled .beam files, protocol consolidations).
  • Conditionally cache: priv/ inside deps if you use NIFs or ports that generate native artifacts during compilation.
  • Never cache: Your application’s own _build/MIX_ENV/lib/YOUR_APP/ directory. This contains your project’s compiled code and must always reflect the current commit. Stale app beams cause phantom test failures.
  • Exclude: ~/.mix and ~/.hex unless you have private Hex repositories configured via environment variables. These global configs often contain credentials or machine-specific paths that break portability.

A practical pattern is to cache _build but add a post-restore step that deletes your app’s own compiled output:

- name: Clean stale app artifacts
  run: rm -rf _build/${MIX_ENV}/lib/my_app

This gives you instant dependency restoration while guaranteeing your application code always compiles fresh against the current source.

How do you troubleshoot cache misses and stale artifacts?

Even with correct configuration, caches fail. Debugging requires understanding the failure modes specific to the BEAM ecosystem. I’ve seen these issues repeatedly in production CI environments:

SymptomLikely CauseFix
Cache hit but compilation fails with "module not found"OTP/Elixir version mismatch between save and restoreAdd version variables to cache key prefix
Full miss on every PR despite unchanged depsLockfile contains OS-specific metadata or timestampsEnsure mix.lock is committed cleanly; check for CRLF issues
Tests pass locally but fail in CI after cache restoreStale protocol consolidation or NIF artifactsDelete _build/*/consolidated and deps/*/priv post-restore
Cache saves successfully but next run ignores itKey exceeds platform length limit or contains illegal charsShorten key; avoid slashes in dynamic segments
Gradual slowdown over weeksCache accumulating dead entries without evictionImplement periodic cache pruning or use platform auto-eviction

When debugging, enable verbose logging in your cache action. In GitHub Actions, set ACTIONS_STEP_DEBUG=true as a repository variable to see exact key lookups and restore-key matches. Verify that your mix.lock hash is stable by running sha256sum mix.lock in consecutive runs with no dependency changes—if it differs, something in your checkout or setup step is modifying the file.

Cache Miss DetectedIs OTP/Elixir version in key?NoYesAdd version vars to keyPrevents cross-version corruptionIs mix.lock hash stable?NoYesFix line endings / checkoutNormalize lockfile formatCheck restore-keys prefixVerify prefix matches keyEnables partial restore fallback
Debugging decision tree for resolving cache misses in Elixir CI dependency caching.

Optimizing Elixir CI Beyond Basic Dependency Caching

Caching dependencies is table stakes. To truly optimize, layer additional strategies on top. First, consider MIX_INSTALL_DIR for scripts and Livebook environments that use inline dependencies—these bypass mix.lock entirely and need separate cache keys based on script content hashes. Second, if you use Docker in CI, leverage multi-stage builds with dedicated cache mounts for /root/.mix and /app/deps to avoid layer bloat.

For teams running self-hosted runners, especially in regions like Nepal where international bandwidth can be variable, consider setting up a local Hex mirror or using HEX_MIRROR pointed at a regional CDN. Combined with aggressive caching, this reduces external network dependency to near-zero. Monitor your cache hit rates as a first-class metric alongside test duration; a dropping hit rate often signals configuration drift before it causes outages. For comprehensive pipeline hygiene, review our CI/CD best practices for small teams.

Next Steps for Faster Elixir Builds

Implementing proper dependency caching transforms your Elixir CI from a patience test into a responsive feedback engine. Start by auditing your current pipeline: measure the time spent in mix deps.get and mix compile before and after applying version-aware cache keys. Track hit rates weekly. If you’re still seeing frequent misses, revisit your key construction and verify that no setup steps mutate the lockfile between checkout and cache lookup.

Need help optimizing your Elixir infrastructure or designing compliant CI/CD systems? Reach out to discuss your DevOps challenges—I help teams build pipelines that are fast, secure, and audit-ready.

Frequently Asked Questions

Use actions/cache with the deps and _build paths, keying on mix.lock hash to restore cached dependencies between workflow runs efficiently.

Cache both deps and _build directories to avoid redownloading packages and recompiling BEAM files during every pipeline execution.

Mismatched cache keys or changed mix.lock hashes prevent hits. Verify path spelling and ensure the hash-files function targets the correct lockfile.

Yes. Restoring cached deps cuts build time by two to four minutes per run, directly lowering metered CI costs on platforms like GitHub Actions.

Yes. Caching _build preserves compiled artifacts and avoids expensive recompilation, provided you invalidate properly when Mix environment or Erlang version changes.

Use hashFiles('mix.lock') in your cache action configuration to create deterministic keys that update only when dependency versions actually change.

Configure restore-keys with branch prefixes to allow fallback restoration from main branch caches while maintaining strict isolation for primary matches.

Delete the specific cache entry via the GitHub UI or API, then rerun the pipeline to regenerate a fresh, valid dependency archive.

Yes. Cached archives contain only compiled code and metadata, not credentials. Ensure HEX_AUTH tokens remain in secrets and never enter cache paths.

Rely on content-addressable keys rather than time rotation. Caches automatically invalidate when mix.lock changes, preventing stale artifact accumulation indefinitely.

Absolutely. Mounting volume caches or using BuildKit cache mounts prevents redundant fetches inside containers, dramatically speeding up layered image builds.

Missing _build directory in cache paths forces recompilation despite restored deps. Include both deps and _build to preserve compiled BEAM bytecode artifacts.

Yes. Define cache keys using file checksums for mix.lock and specify untracked paths for deps and _build in your pipeline configuration.

GitHub Actions enforces a 10GB total repository cache limit. Monitor usage via the API and prune old entries if approaching this threshold.

Enable verbose logging in the cache action to inspect computed keys, matched restore-keys, and exact path resolution during restore steps.