
Table of Contents
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.
mix.lock file combined with OTP/Elixir versions as the cache key. Configure your CI tool to persist both deps/ and _build/ directories, using partial restore keys to allow graceful fallback when lockfiles change.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.
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.
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:
~/.mixand~/.hexunless 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:
| Symptom | Likely Cause | Fix |
|---|---|---|
| Cache hit but compilation fails with "module not found" | OTP/Elixir version mismatch between save and restore | Add version variables to cache key prefix |
| Full miss on every PR despite unchanged deps | Lockfile contains OS-specific metadata or timestamps | Ensure mix.lock is committed cleanly; check for CRLF issues |
| Tests pass locally but fail in CI after cache restore | Stale protocol consolidation or NIF artifacts | Delete _build/*/consolidated and deps/*/priv post-restore |
| Cache saves successfully but next run ignores it | Key exceeds platform length limit or contains illegal chars | Shorten key; avoid slashes in dynamic segments |
| Gradual slowdown over weeks | Cache accumulating dead entries without eviction | Implement 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.
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.