Infrastructure worth keeping — and kept patched.

Reuse Gradle results in Docker CI, even in fresh containers

A new container with an empty Gradle home can take its compile result from the cache, so containers that keep nothing still skip work. We checked this in real Linux container runs, with a different mount path and with no password passed to the container.

The short answer

What we ran

The cache server ran on a Mac, with a username and password. Gradle ran in the gradle:9.8.0-jdk21 image (Linux, aarch64, Java 21) under Docker Desktop on that same Mac. Every build was a new docker run --rm with no volumes for the Gradle home, so the container started empty and kept nothing. The project folder was mounted from the Mac. The local build cache was off. The only Gradle state shared between builds was the cache server; the image, the Docker VM and the Mac were the same for every build. Each build is one run, not an average. Each used a new copy of the project's files. Our first set of runs started from a project folder that still held build output from an earlier check on the Mac; we then repeated every run from a clean copy and got the same results.

docker run --rm -e FSCACHE_PASSWORD=<your password> \
  -v "$PWD/project:/work/one" -w /work/one \
  gradle:9.8.0-jdk21 gradle :core:compileJava --console=plain --no-daemon

The project is the four-module build from Gradle build cache for a monorepo. We looked at the :core:compileJava task.

RunWhat changedResult
1First containerRan
2New container, same code, same mount pathFROM-CACHE
3New container, same code, mounted at a different pathFROM-CACHE
4New container, a line of main code editedRan
5New container, no password passedRan. A 401 line, then the remote cache disabled. BUILD SUCCESSFUL

Runs 1 to 4 ended with BUILD SUCCESSFUL and printed no 401 line. The server's address was host.docker.internal, which is how a container on Docker Desktop reaches the Mac.

The settings file

This is the cache part of settings.gradle.kts we used. It reads the password from an environment variable, so the password is not in the project. We used plain HTTP because the test ran on one machine; use HTTPS across a network.

buildCache {
    local { isEnabled = false }
    remote<HttpBuildCache> {
        url = uri("http://host.docker.internal:18250/")
        isPush = true
        isAllowInsecureProtocol = true
        credentials { username = "ci"; password = System.getenv("FSCACHE_PASSWORD") }
    }
}

What this means for CI

Could not load entry e3c6ab11d62f857d59bb3a92b7941080 from remote build cache: Loading entry from 'http://host.docker.internal:18250/e3c6ab11d62f857d59bb3a92b7941080' response status 401: Unauthorized
...
The remote build cache was disabled during the build due to errors.

For more on this error, see Gradle remote build cache authentication and 401 errors.

The last test ran two builds inside one container with Gradle's background process on. The first build had no password and printed the 401 line. Then we set the password in the same shell, deleted core/build, and the second build printed :core:compileJava FROM-CACHE. So the background process picked up the later password; no restart was needed. We saw the same in an earlier run on the Mac itself, with no container.

We did not test CI products other than GitHub Actions, a cache on another host, HTTPS, or Windows. The first runs used the Linux inside Docker Desktop on a Mac; the second set, in the note at the end, used a GitHub-hosted Linux machine.

If it does not come from the cache

Work through why the hit rate is low and Jenkins and CI build cache. For read-only developer logins, see CI writes, developers read.

Run on Gradle 9.8.0 (image gradle:9.8.0-jdk21, Linux aarch64, Java 21) with FosterStack Cache 0.2.1, Docker Desktop 4.87 on macOS (arm64).

We ran the five runs again on a GitHub-hosted Linux machine (amd64) with FosterStack Cache 0.2.2 and the same image tag (gradle:9.8.0-jdk21, its amd64 build), with the cache server on the same machine and each run on a fresh copy of the project folder. The results were the same: the 401 line appeared in run 5, run 5 ended with BUILD SUCCESSFUL, and the background-process test behaved as described above. On a Linux machine without Docker Desktop, we added --add-host=host.docker.internal:host-gateway to the docker run command so the container could reach the server by that name. Start each test build from a clean copy of the project. When we ran the same command again on a copy of the same folder, build output included, the build printed :core:compileJava UP-TO-DATE, not FROM-CACHE: Gradle found its own earlier output in the folder, so the cache was never asked and the test showed nothing. The run and its log are public.