Caching in CI/CD: The Easiest Win Most Teams Leave on the Table
by Arif Ikhsanudin, Backend Developer
Downloading the Same 400 MB on Every Run
Every time your CI pipeline runs, it downloads your dependencies. For a Spring Boot project with a moderate dependency tree, that's 150–400 MB of JAR files from Maven Central. For a Node.js project, it might be 300 MB in node_modules. For a Python service with NumPy, SciPy, and Pandas, it can be 500 MB or more.
This download happens on every run — every push, every PR, every retried build — because CI runners are ephemeral. They start clean, do the work, and terminate. The next run starts from scratch.
Without caching, a typical Java service wastes 2–4 minutes per pipeline run on dependency resolution. Across a team of 10 pushing 30 commits per day, that's 60–120 minutes of pure download time daily, contributing nothing to the quality of the build.
The Three Caching Strategies
Dependency caching is the most impactful and easiest to implement. Most CI platforms support it natively.
# GitHub Actions: Java/Gradle
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'gradle'
# GitHub Actions: Node.js
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# GitHub Actions: Python
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
These actions automatically handle cache key generation (based on the hash of your lockfile) and cache restoration. If the lockfile hasn't changed, dependencies are restored from cache. If it has, new dependencies are downloaded and the cache is updated.
Build output caching goes further — caching compiled classes, test results, and intermediate build artifacts so that unchanged modules don't need to be recompiled. Gradle's build cache does this particularly well:
// settings.gradle.kts
buildCache {
local {
directory = File(rootDir, "build-cache")
removeUnusedEntriesAfterDays = 30
}
// For teams: use a remote build cache server
remote<HttpBuildCache> {
url = uri("https://build-cache.internal/cache/")
credentials {
username = System.getenv("GRADLE_CACHE_USER")
password = System.getenv("GRADLE_CACHE_PASSWORD")
}
push = System.getenv("CI") == "true" // Only push from CI, not local builds
}
}
With a remote build cache, if engineer A already compiled module X today, engineer B's CI run can reuse that compiled output instead of recompiling. On large codebases, this eliminates most of the compile time.
Docker layer caching is the third tier. Docker builds are layer-based, and layers are only rebuilt when their inputs change. The key is structuring your Dockerfile to maximize cache reuse:
# Bad: every build is a full rebuild because the COPY invalidates everything
FROM eclipse-temurin:21-jre
COPY . .
RUN ./gradlew bootJar
# Good: dependencies layer is stable across most builds
FROM eclipse-temurin:21-jre AS deps
WORKDIR /app
COPY build.gradle settings.gradle gradle/ ./
RUN ./gradlew dependencies --no-daemon
FROM deps AS build
COPY src/ src/
RUN ./gradlew bootJar --no-daemon
FROM eclipse-temurin:21-jre
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
In GitHub Actions, use docker/build-push-action with cache-from and cache-to pointing to your container registry:
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myrepo/myapp:latest
cache-from: type=registry,ref=myrepo/myapp:cache
cache-to: type=registry,ref=myrepo/myapp:cache,mode=max
Cache Key Design
A cache key has two requirements: it must be stable enough that cache hits are frequent, and it must change when the cached content is invalid.
For dependency caches, the lockfile hash is the right key — pom.xml, package-lock.json, requirements.txt, go.sum. These files change exactly when dependencies change.
For build caches, keys should include the source files that affect the output — typically a hash of the source directory combined with the dependency key.
# GitHub Actions: manual cache with proper key
- uses: actions/cache@v4
with:
path: ~/.gradle/caches
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
The restore-keys fallback is important: if no exact key match exists (because the lockfile changed), use the most recent partial match. Starting with the closest available cache is better than starting cold.
Measuring the Impact
Before and after cache configuration, measure the Set up dependencies (or equivalent) step duration across 20 runs. Most teams see a reduction from 2–4 minutes to 5–15 seconds on cache hits. That's the fast path — the optimization that makes every other investment in your pipeline more valuable.