← All posts
7 min readThe Cachely team

The Gradle build cache in Docker and CI: what to persist and what to mount

"Gradle build cache in Docker" usually means three different caches are being talked about at once: Docker's image-layer cache, Gradle's dependency cache, and Gradle's build cache. They solve different problems, live in different places, and the classic containerised-CI mistake is persisting the wrong one - or none of them - and concluding that caching "does not work in Docker". Here is how the three fit together, what to mount where, and why ephemeral runners change the answer.

Three caches, three jobs

  • Docker layer cache - caches whole image-build steps. Coarse by design: a RUN ./gradlew build layer is reused only if nothing in the layers above it changed, so a one-line source edit re-runs the entire build. Good for the image's OS and toolchain layers; a blunt instrument for compilation.
  • Gradle dependency cache - downloaded artifacts and metadata under ~/.gradle/caches/modules-2, plus wrapper distributions under ~/.gradle/wrapper. Saves network time; has nothing to do with whether a task re-executes.
  • Gradle build cache - task outputs keyed by input hashes, locally under ~/.gradle/caches/build-cache-1 or on a remote server. This is the cache that turns compileJava into FROM-CACHE and is what "build cache" properly refers to.

All the Gradle state hangs off one root: GRADLE_USER_HOME, which defaults to ~/.gradle. That variable is the whole trick to caching in containers - control where it points, and you control what survives.

Building inside a Dockerfile: mount, do not bake

Copying sources and running Gradle inside docker build means every cache starts empty on every build unless you say otherwise, because each build gets a fresh filesystem. Baking ~/.gradle into an image layer is the wrong fix - it bloats the image and snapshots a cache that is stale by the next commit. The right tool is a BuildKit cache mount, which persists a directory across builds without ever entering the image:

# syntax=docker/dockerfile:1
FROM gradle:8-jdk21 AS build
WORKDIR /home/gradle/project
COPY --chown=gradle:gradle . .
RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
    gradle build --build-cache

The mount carries the dependency cache and the local build cache between image builds on the same builder. Two caveats: the cache lives on the builder host, so a fleet of CI machines does not share it; and the official gradle images run as the gradle user, so the mount target and ownership flags must match or Gradle silently writes a cache it cannot reuse.

Running Gradle in a container on CI: persist GRADLE_USER_HOME

When CI runs Gradle inside a container (rather than building an image), the container filesystem is discarded after the job. Point GRADLE_USER_HOME at a path your CI system can persist, and restore it between runs:

# docker run: mount a host directory as the Gradle home
docker run -e GRADLE_USER_HOME=/workspace/.gradle-home \
  -v "$PWD:/workspace" -w /workspace \
  gradle:8-jdk21 gradle build --build-cache

On managed CI, the same idea is the platform's cache step keyed on your lockfiles or version catalog, restoring ~/.gradle/caches/modules-2 and ~/.gradle/wrapper. Restore the dependency cache this way by all means - it is append-mostly and compresses well. Restoring build-cache-1 through a CI cache step is where diminishing returns set in, which brings us to the real answer.

Ephemeral runners want a remote build cache

Uploading and downloading the local build cache directory as a CI artifact fights the design of both systems:

  • The tarball grows with every entry, so save/restore time climbs until it eats the minutes the cache saved.
  • CI cache steps are usually branch-scoped, so parallel jobs and sibling branches each rebuild the same outputs.
  • Your laptop never benefits: work done in CI stays in CI.

The build cache was designed with a server-side answer: a remote build cache that every ephemeral container, CI job, and developer machine reads over HTTP. Each task fetches exactly the entries it needs - no tarball, no branch scoping, no warm-up job. Configuration is a few lines of settings.gradle.kts:

// settings.gradle.kts
buildCache {
    local { isEnabled = true }
    remote<HttpBuildCache> {
        url = uri("https://remote.cachely.dev")
        credentials {
            username = "cachely"
            password = providers.environmentVariable("GRADLE_CACHE_TOKEN").orNull
        }
        isPush = providers.environmentVariable("CI").isPresent
    }
}

The isPush guard is the pattern worth copying: CI populates the cache, everything else only reads it. Wire the same token into a read-only role for pull requests and untrusted forks. Setup, token handling, and how to verify FROM-CACHE hits end to end are covered in our Gradle remote build cache guide.

Node builds driven by Gradle

Plenty of Gradle builds shell out to npm or pnpm through a plugin such as com.github.node-gradle, and the caching instinct is to make npmInstall cacheable. Resist it: node_modules is huge, full of platform-specific binaries, and generally not relocatable across machines, so caching the install task produces enormous entries that are unsafe to restore elsewhere. Cache the package manager's download cache alongside the Gradle dependency cache instead, and make the bundling task the cacheable one - it has honest file inputs (sources, lockfile, bundler config) and a compact output directory:

// build.gradle.kts - cache the bundle, not the install
tasks.register<com.github.gradle.node.npm.task.NpmTask>("bundle") {
    args.set(listOf("run", "build"))
    inputs.dir("src")
    inputs.files("package.json", "package-lock.json", "vite.config.ts")
    outputs.dir("dist")
    outputs.cacheIf { true }
}

With declared inputs and outputs, the bundle task participates in the build cache like any compile task - including the remote cache, which is exactly what an ephemeral container needs to skip a webpack or Vite run it has never seen locally.

A checklist for containerised Gradle caching

  • Decide which of the three caches each mechanism serves; never bake ~/.gradle into image layers.
  • In docker build, use RUN --mount=type=cache targeting the Gradle home, with ownership matching the build user.
  • In CI containers, set GRADLE_USER_HOME to a persisted path and restore the dependency cache between jobs.
  • Enable the build cache explicitly - org.gradle.caching=true in the checked-in gradle.properties or --build-cache per run.
  • Share task outputs across ephemeral runners with a remote build cache; keep pushes to CI and reads for everyone else.
Put a shared build cache behind your builds
Free tier, no credit card - connect Nx, Turborepo, Gradle, or Bazel in about five minutes.
Start freeSee pricing