Pipeline caches to speed builds

Use pipeline caches to speed up builds — CI/CD foundations tutorial. Learn how to cache dependencies and build artifacts to reduce build times, with hands-on steps and troubleshooting.

Focus: use pipeline caches to speed up builds

Sponsored

Every time your pipeline starts from zero, it re-downloads the same dependencies, re-installs the same packages, and recompiles the same code that it built just minutes ago. This waste isn't just annoying — it slows down feedback loops, burns CI minutes, and makes developers wait longer for results. In this lesson, you'll learn how to use pipeline caches to speed up builds, transforming your CI from a slow, repetitive chore into a fast, efficient machine.

The problem this lesson solves

Imagine you push a one-line fix to a typo in your README. Your CI pipeline kicks off, and suddenly it's running npm install (or pip install or go mod download) again, downloading hundreds of megabytes from the internet. Then it compiles every source file, runs the full test suite, and packages the artifact — even though only a few lines changed. This is the default behavior of most CI systems: every run starts with a clean slate.

Why is that a problem? First, speed: each run can take 5–15 minutes longer than necessary, just re-fetching and re-installing what you already had. Second, cost: CI providers charge per minute or per parallel job, so wasteful builds eat your budget. Third, developer experience: slow feedback means bugs take longer to surface, and merges slow down. Fourth, flakiness: network hiccups or version mismatches can break builds even when your code is fine.

The solution is pipeline caching — storing the output of expensive steps (like dependency downloads or compiled artifacts) between runs, and reusing them when nothing relevant changed. This lesson shows you exactly how to do it, with practical examples you can apply today.

Core concept / mental model

Think of a pipeline cache as a fast-food drive-through versus cooking from scratch. Without a cache, every build is like going to the grocery store, buying all ingredients, and cooking every meal from zero — even if you made the same dish yesterday. With a cache, you keep the pantry stocked: the moment you need the same dependency or compiled artifact, it's already there, saving you the shopping and cooking time.

Technically, a CI cache is a key-value store. The key identifies what's being cached (e.g., a hash of your lockfile or a version string), and the value is the data — a directory of downloaded packages, a compiled binary, or a build output. At the start of a job, the pipeline checks if the key exists; if yes, it restores the cache. At the end, it saves a new cache entry under the same key.

Here’s the key mental shift: caching is about what you reuse, not what you run. You still run the same steps — install, build, test — but with caching, the slowest parts become no-ops because the result is already available. The cache is scoped by path (what folders to store) and key (when to reuse).

How it works step by step

Caching follows a predictable pattern, which you can apply to any CI system. Here’s the logical sequence:

  1. Identify slow, repeatable steps. Look for steps that fetch or produce identical outputs every run — dependency installs (npm, pip, Maven, Go), build artifacts, test fixtures, or even Docker layers.
  2. Define a cache key. The key must change when the content changes. For dependencies, this is usually a hash of your lockfile (e.g., package-lock.json, requirements.txt, go.sum). For build outputs, it could be the source version, branch, or a hash of critical source files.
  3. Set the cache path(s). Specify which directories to store — e.g., ~/.npm, node_modules, venv, target. These are the folders that the pipeline will restore and save.
  4. Restore before the heavy step. At the start of the job, use the key to fetch the cache. If found, your dependency directory is already populated.
  5. Run the step normally. The install command sees existing files and skips re-downloading (or verifies they match).
  6. Save the cache after the step. Once the step completes, save the updated cache under the key — so the next run can reuse it.

The cause-and-effect is simple: each saved cache is a gift to the next run, and each restored cache saves minutes that would otherwise be spent on network and CPU.

Hands-on walkthrough

Let’s implement caching in two popular CI platforms. We’ll start with GitHub Actions, since it’s the most widely used and has built-in cache actions.

Example 1: GitHub Actions — caching npm dependencies

Here’s a workflow that installs Node dependencies, runs tests, and builds the app — with caching to speed up the install step:

name: Node CI

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Cache node modules
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Build
        run: npm run build

How it works: - path: ~/.npm — caches the global npm cache (where downloaded packages are stored). npm ci then reads from there instead of hitting the network. - key: ...-hashFiles('package-lock.json') — when the lockfile changes, the key changes, so a fresh cache is created. If unchanged, the same key restores the cache from a previous run. - restore-keys — a fallback: if an exact match isn’t found, it uses the most recent cache with a prefix (e.g., any linux-node- cache). This helps when the lockfile changes but most dependencies are the same.

Example 2: GitLab CI — caching Python dependencies

GitLab CI uses a cache keyword in your .gitlab-ci.yml:

image: python:3.11

cache:
  paths:
    - .venv/
  key:
    files:
      - requirements.txt

test:
  script:
    - python -m venv .venv
    - source .venv/bin/activate
    - pip install -r requirements.txt
    - pytest

How it works: - paths — caches the entire virtual environment directory. If it exists, pip install sees all packages already installed and skips re-installing. - key: files — the key is derived from requirements.txt content. When that file changes, the cache is invalidated and recreated.

Example 3: Simple Python build caching (without CI platform)

If you’re building a custom pipeline or want to understand the mechanics, here’s a minimal script that caches a compiled binary based on source modification time:

import hashlib
import os
import shutil
import subprocess

CACHE_DIR = "build_cache"


def cache_key(source_path: str) -> str:
    """Generate a key from file content hash."""
    hasher = hashlib.sha256()
    with open(source_path, "rb") as f:
        hasher.update(f.read())
    return hasher.hexdigest()


def build_cached(source: str, output: str):
    key = cache_key(source)
    cache_path = os.path.join(CACHE_DIR, key)

    # Restore from cache if it exists
    if os.path.exists(cache_path):
        print("Cache hit — restoring build output")
        shutil.copy(cache_path, output)
        return

    # Build (simulate compilation)
    print("Cache miss — building fresh")
    with open(source, "r") as f:
        content = f.read()
    with open(output, "w") as f:
        f.write(f"compiled: {content}")

    # Save to cache
    os.makedirs(CACHE_DIR, exist_ok=True)
    shutil.copy(output, cache_path)


# Example usage
if __name__ == "__main__":
    build_cached("source.txt", "output.bin")
    # Second call should hit cache
    build_cached("source.txt", "output.bin")

Expected output:

Cache miss — building fresh
Cache hit — restoring build output

This demonstrates the exact logic your CI uses: check key, restore or build, then save.

Pro tip: Always set a restore key or fallback in your cache configuration. This prevents cache invalidation from causing a full re-download when only a small part of the key changes.

Compare options / when to choose what

Not all caching is equal. Here’s how the main approaches compare:

Approach How it works Best for Drawbacks
Dependency cache Stores package manager’s cache dir (e.g., ~/.npm, .venv) Frequent, repeated dependency installs Large cache size; still re-runs install step
Artifact cache Stores compile outputs (e.g., target/, dist/) Slow compilation steps Risk of stale artifacts if not keyed correctly
CI built-in cache GitHub Actions actions/cache, GitLab cache, Jenkins stash Zero extra tooling Platform-specific syntax; limits on cache size
External cache Separate service like Nexus, Artifactory, or S3 Cross-pipeline sharing, central control Extra infra to manage
Docker layer caching Caches image build layers Docker-based projects Can be flaky on CI runners; requires special config

When to choose what: - If you’re just starting, use built-in cache — it’s the least effort. - If your bottleneck is dependency installs, use dependency caching. - If your bottleneck is compilation (e.g., C++, Rust, or large Java builds), use artifact caching. - If you have multiple pipelines that share dependencies, consider an external cache for central management. - For containerized builds, Docker layer caching can dramatically speed up image builds.

Troubleshooting & edge cases

Cache key too stable — If your key never changes, old cache gets reused even when your dependencies should update. Always include lockfile hashes or version numbers.

Cache key too volatile — If your key changes every run (e.g., using current timestamp), you’ll never get a cache hit. Use content hashes or version tags, not time.

Cache too large — Many CI providers cap cache size (e.g., GitHub Actions free tier is 10 GB). Trim your cache paths to only what’s necessary. For instance, don’t cache node_modules if ~/.npm is enough — that reduces both size and restore time.

Restoring corrupt cache — If a cached directory becomes corrupted (e.g., interrupted save), your build may fail. Add a fallback to npm ci or pip install that runs if the cache is incomplete, or use --prefer-offline for npm only when valid.

Platform-specific paths — Different OSes have different cache locations. Always use runner.os in your key or use platform-specific paths.

Cache save failure — Sometimes the save step may fail (e.g., quota exceeded). Ensure this doesn’t fail the whole job by making save steps non-blocking (GitHub Actions actions/cache handles this by default).

What you learned & what's next

You’ve learned how to use pipeline caches to speed up builds — from understanding the mental model of caching to implementing it in GitHub Actions and GitLab CI. You can now explain the core idea behind caching, define cache keys, and apply caching in a practical exercise. These skills will save you and your team minutes on every pipeline run, speeding up delivery and reducing CI costs.

As a next step, you might explore pipeline artifacts — how to store and pass build results between jobs — or dive into parallel job execution to further reduce build times. Caching and artifacts often work together: cache dependencies for speed, and ship artifacts as the final output. Stay tuned for the next lesson in this track, which builds on these fundamentals.

Practice recap

Practice recap: Modify a GitHub Actions workflow for a simple Node.js project to add caching for ~/.npm with a hashFiles('package-lock.json') key. Run the pipeline twice — the first run should miss the cache, the second should hit it and show a faster install step. Observe how the cache key changes when you bump a dependency version in the lockfile.

Common mistakes

  • Using too stable a cache key (e.g., just the branch name) so stale dependencies get reused for weeks, causing mysterious build failures.
  • Caching too much data — like entire node_modules instead of the npm cache — which bloats the cache and slows down restore, sometimes more than the build saves.
  • Setting volatile cache keys (e.g., timestamps or build numbers) so you never get a cache hit, making the cache useless.
  • Restoring a corrupted cache and not having a fallback to re-download dependencies, which breaks the build and confuses developers.
  • Forgetting to include the OS or runner architecture in the cache key, leading to cache corruption when switching between Ubuntu and Windows runners.

Variations

  1. Use actions/cache@v4 with restore-keys to get partial hits even when the lockfile changes slightly — a smarter fallback than a single key.
  2. Instead of caching the npm cache directory, you can cache the entire node_modules folder directly, though this may be larger and less portable.
  3. For Docker-based builds, enable Docker layer caching (e.g., in GitHub Actions with docker/build-push-action's cache-from parameter) to reuse image layers across runs.

Real-world use cases

  • A startup reduces its Rails CI time from 8 minutes to 2 minutes by caching the vendor/bundle directory across runs, speeding up every pull request.
  • An enterprise Java project caches Maven's ~/.m2 repository, avoiding re-downloading the same dependencies for hundreds of daily builds and significantly cutting cloud compute costs.
  • A mobile app team uses caching for CocoaPods and Gradle caches on their CI, which cuts Android build times from 15 to 5 minutes, helping them meet release deadlines.

Key takeaways

  • Pipeline caching stores the output of expensive, repeatable steps and reuses it on subsequent runs, saving time and CI minutes.
  • A proper cache key is content-based — hash your lockfile or source — so the cache only invalidates when relevant dependencies change.
  • Use CI built-in cache actions (like GitHub Actions actions/cache or GitLab cache keyword) for the easiest setup, and configure both key and restore-keys for best hit rates.
  • Cache only what's necessary — dependency directories, compiled artifacts, or Docker layers — to avoid bloating the cache and hurting performance.
  • Always design for cache misses and corrupted caches by having the install step re-run cleanly if the cache is incomplete.
  • Effective caching directly accelerates feedback loops, lowers CI costs, and improves developer experience.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.