Use a remote build cache in GitHub Actions
Point your GitHub Actions builds at your own FosterStack Cache server so they reuse each other's work, with no password in the repository. Store the cache address and logins as five GitHub secrets, named in step 1. Read them from environment variables in your Gradle or Maven settings. Let only builds on main store results.
First: can the runner reach your server?
A GitHub-hosted runner is a machine on GitHub's network, not on yours. It can only use a cache server at an address it can reach: a public HTTPS address, or a self-hosted runner on the same network as the server. We did not test any particular hosting. If the server is on the internet, put HTTPS in front of it. See production build cache: Docker Compose, nginx and TLS.
1. Store five secrets
In your repository, add these under Settings, Secrets and variables, Actions. Use the two logins from let CI write, developers only read.
CACHE_URL: the address of your cache server.CACHE_RW_USERandCACHE_RW_PASSWORD: the read-write login, for builds on main.CACHE_RO_USERandCACHE_RO_PASSWORD: the read-only login, for pull requests.
2. Gradle: read the settings from environment variables
In settings.gradle.kts:
buildCache {
remote<HttpBuildCache> {
val cacheUrl = providers.environmentVariable("CACHE_URL").orNull
isEnabled = !cacheUrl.isNullOrBlank()
if (!cacheUrl.isNullOrBlank()) {
url = uri(cacheUrl)
}
isPush = providers.environmentVariable("CACHE_PUSH").orNull == "true"
credentials {
username = providers.environmentVariable("CACHE_USER").orNull
password = providers.environmentVariable("CACHE_PASSWORD").orNull
}
}
}
And in gradle.properties, so the build cache is on:
org.gradle.caching=true
With no CACHE_URL the remote cache is switched off and the build runs as it always did. That is what a fork pull request gets.
3. The workflow
name: build
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build
env:
CACHE_URL: ${{ secrets.CACHE_URL }}
CACHE_USER: ${{ github.event_name == 'push' && secrets.CACHE_RW_USER || secrets.CACHE_RO_USER }}
CACHE_PASSWORD: ${{ github.event_name == 'push' && secrets.CACHE_RW_PASSWORD || secrets.CACHE_RO_PASSWORD }}
CACHE_PUSH: ${{ github.event_name == 'push' && 'true' || 'false' }}
run: ./gradlew build
The secrets go into env: and are read from the environment. They are never written into the run: text. This uses the runner's own Java and the Gradle wrapper; set up Java your usual way.
Maven
Maven needs three small files. The cache config keeps the remote switched off, so a build without the variables is a plain build. In .mvn/maven-build-cache-config.xml:
<?xml version="1.0" encoding="UTF-8"?>
<cache xmlns="http://maven.apache.org/BUILD-CACHE-CONFIG/1.2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/BUILD-CACHE-CONFIG/1.2.0 https://maven.apache.org/xsd/build-cache-config-1.2.0.xsd">
<configuration>
<enabled>true</enabled>
<remote enabled="false" id="fosterstack-cache">
<url>https://cache.example.com/</url>
</remote>
</configuration>
</cache>
The extension itself is set up in .mvn/extensions.xml; see Maven build cache extension: remote server setup. The login goes in .mvn/ci-settings.xml. Maven fills in the ${env.NAME} parts from the environment:
<settings>
<servers>
<server>
<id>fosterstack-cache</id>
<username>${env.CACHE_USER}</username>
<password>${env.CACHE_PASSWORD}</password>
</server>
</servers>
</settings>
Use the same env: block as above and this run: step. Only pass the cache flags when there is a URL:
run: |
if [ -n "$CACHE_URL" ]; then
mvn -B -s .mvn/ci-settings.xml \
-Dmaven.build.cache.remote.enabled=true \
-Dmaven.build.cache.remote.url="$CACHE_URL" \
-Dmaven.build.cache.remote.save.enabled="${CACHE_PUSH:-false}" \
verify
else
mvn -B verify
fi
The guard matters. On Maven 3.9.9, without it, Maven stops when there is no address. On Maven 3.10.0 it carried on and built without the remote cache, but logged [ERROR] Error downloading cache item, so keep the guard there too (see the table below).
Pull requests and forks
Our advice, not a test result:
- Give pull requests the read-only login. The workflow above does not hand them the read-write login, so a pull request can use what main stored but cannot change it through this workflow. See can someone poison our shared build cache.
- Remember that anyone who can push a branch can edit the workflow in that branch. For stronger separation, keep the read-write secrets in a GitHub environment that only main can use. On private repositories environments need a paid GitHub plan.
- Do not hand the read-write login to pull requests. Keep it for builds on main.
- Expect fork pull requests to build without the cache. By default GitHub does not pass secrets to the runner when a workflow is triggered from a forked repository (GitHub's page on secrets). With the settings above that is a plain build, which is fine.
- Avoid
pull_request_targetfor builds. It runs with secrets. If it also checks out the pull request's code, that code runs with those secrets (GitHub's page on events).
How we checked
We ran both recipes against a real FosterStack Cache 0.2.1 server on a Mac. The server had a read-write login and a read-only login, both made up for the test. The cache address and logins reached the build only as environment variables.
| Build | Gradle | Maven |
|---|---|---|
| Main build, read-write login, push on | Both compile tasks ran and were stored | Built; stored 3 files |
| Main build again from a fresh project | Both came from the cache | Restored from the cache |
| Pull request: read-only login, push off, one file changed | The unchanged module came from the cache, the changed one ran, nothing was stored | Built (the one module changed); nothing stored |
| Read-only login with push on | Build succeeded. Could not store entry ... response status 403: Forbidden, then The remote build cache was disabled during the build due to errors. Nothing stored | Build succeeded. Unable to save to remote cache ... with a 403 stack trace, 3 times. Nothing stored |
| No variables at all, or all of them empty (fork pull request) | Build succeeded without the cache. No cache warning was printed | Build succeeded without the cache (no flags passed) |
| Address set, login empty (Gradle), missing, or a wrong password | Build succeeded. Could not load entry ... response status 401: Unauthorized | Build succeeded. [ERROR] Error downloading cache item (Maven 3.9.9) |
| Cache flags passed with an empty address | Not applicable | Maven 3.9.9 stopped with an internal error: NoTransporterException: Cannot access using the registered transporter factories. Maven 3.10.0 did not stop (see the hosted-runner list below) |
On a GitHub-hosted machine (Ubuntu 24, 4 CPUs, Java 21.0.12.1, Gradle 9.8.0). We also ran the Gradle settings from step 2 in a GitHub Actions job, with the cache address and logins passed as environment variables. A FosterStack Cache server ran on the same machine with made-up logins. Four builds:
| Build | Library compile | App compile | Entries on the server |
|---|---|---|---|
| Main build, read-write login, push on | ran | ran | 0 to 6 |
| Main build again, fresh checkout | from cache | from cache | 6 to 6 |
| Pull request: read-only login, push off, app changed | from cache | ran | 6 to 6 |
| All variables empty (fork pull request) | ran | ran | 6 to 6 |
The script also wrote straight to the server with the read-only login and with a wrong password, and would have stopped the run unless it got HTTP 403 and 401. The run passed. The run and its log are public, and the script is bin/demo-github-actions.sh. The server ran on the same machine, so this shows the settings and the environment-variable mechanism work.
The page's own workflow steps, on a hosted runner. We then ran the checkout and Build steps from this page, for Gradle and for Maven, in a GitHub Actions job. The setup was Ubuntu 24, 4 CPUs, FosterStack Cache 0.2.2, Java 21.0.12.1, Gradle 9.8.0, the runner's own Maven 3.10.0 and extension 1.2.3. A step started the cache server on the same machine.
Two things were swapped in: made-up logins in place of the five secrets, and a fixed value in place of github.event_name, because a manual run is neither a push nor a pull request. Besides a working directory for the Maven step and a build log for our checks, nothing else in the steps changed. We read the results from the server's counters and the build logs. First, the cases that the page's own Build steps ran:
- Gradle, main build with the read-write login: both compile tasks ran and the server stored 5 entries. The same build again from a fresh checkout: both came from the cache and nothing new was stored.
- Gradle, pull request with the read-only login and one module changed: the unchanged module came from the cache, the changed one ran, and nothing was stored.
- Gradle, every variable empty (a fork pull request): both ran and the server saw no request.
- Maven, the same four builds: the main build stored 3 files; the next build restored from the cache; the pull request built and stored nothing; the fork build ran without the cache and the server saw no request.
The error cases cannot come from the workflow as written, so they ran the same build commands from a script, with the variables set by hand (the Maven 403 case also turned saving on with the read-only login):
- Gradle, read-only login with push on: the build succeeded, the log had the 403 and the "remote build cache was disabled" lines, and nothing was stored. A wrong password, or an address with an empty login, gave the 401 line.
- Maven, read-only login with push on: three "Unable to save to remote cache" lines and nothing stored. A missing login or a wrong password: the build succeeded with "Error downloading cache item".
- Maven 3.10.0 with the cache flags and an empty address did not stop the build. It ran without the remote cache and logged "Error downloading cache item". The
NoTransporterExceptionin the table is the Maven 3.9.9 result.
The local runs used FosterStack Cache 0.2.1 and Maven 3.9.9. The hosted-runner jobs used 0.2.2 and, for Maven, 3.10.0. The run and its log are public. The server ran on the same machine, so this does not show that a hosted runner can reach your own server.
Local runs: Gradle 9.8.0 with Java 21.0.12.1. Maven 3.9.9 with Build Cache Extension 1.2.3 and all eight plugin versions pinned. Every build used a fresh copy of the project and an empty local cache. Gradle built a two-module project with the compile tasks only. Maven built a one-module project with two tests. macOS (arm64), one machine.
Not tested: the workflow file with real secrets (the hosted-runner jobs used made-up logins), a hosted runner reaching a real server, an empty Maven login (only a missing login and a wrong password were run), Maven versions other than 3.9.9 and 3.10.0, and secrets that are missing as opposed to empty in a real fork pull request. The local Maven runs used Maven 3.9.9. On Maven 3.10.0 an ordinary first-time miss is also logged as [ERROR], so that line alone does not mean a login problem (shown in Maven build cache extension: remote server setup).