Infrastructure worth keeping — and kept patched.

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.

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:

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.

BuildGradleMaven
Main build, read-write login, push onBoth compile tasks ran and were storedBuilt; stored 3 files
Main build again from a fresh projectBoth came from the cacheRestored from the cache
Pull request: read-only login, push off, one file changedThe unchanged module came from the cache, the changed one ran, nothing was storedBuilt (the one module changed); nothing stored
Read-only login with push onBuild succeeded. Could not store entry ... response status 403: Forbidden, then The remote build cache was disabled during the build due to errors. Nothing storedBuild 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 printedBuild succeeded without the cache (no flags passed)
Address set, login empty (Gradle), missing, or a wrong passwordBuild succeeded. Could not load entry ... response status 401: UnauthorizedBuild succeeded. [ERROR] Error downloading cache item (Maven 3.9.9)
Cache flags passed with an empty addressNot applicableMaven 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:

BuildLibrary compileApp compileEntries on the server
Main build, read-write login, push onranran0 to 6
Main build again, fresh checkoutfrom cachefrom cache6 to 6
Pull request: read-only login, push off, app changedfrom cacheran6 to 6
All variables empty (fork pull request)ranran6 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:

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):

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).