From e7460652884591784a2a5046b0e08316f88e4409 Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:50:27 -0400 Subject: [PATCH 01/25] Page: Build Cache Node migration checklist (real before/after run) --- .../index.html | 153 ++++++++++++++++++ sitemap.xml | 4 + 2 files changed, 157 insertions(+) create mode 100644 build-cache-node-migration-checklist/index.html diff --git a/build-cache-node-migration-checklist/index.html b/build-cache-node-migration-checklist/index.html new file mode 100644 index 0000000..2b82d4a --- /dev/null +++ b/build-cache-node-migration-checklist/index.html @@ -0,0 +1,153 @@ + + + + + +Build Cache Node migration checklist for Gradle and Maven + + + + + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Build Cache Node migration checklist for Gradle and Maven

+

A short checklist for moving builds from the free Develocity Build Cache Node to FosterStack Cache, with a real run to back it: the same project built against an old node, then against FosterStack Cache 0.2.1, then back. Read why the node is going away in Build Cache Node end of life.

+ +

What I tested

+

Gradle 9.8.0 and a small Java project, built twice against each server. The old side was the final Build Cache Node image (version 21.2), run locally and set up to allow anonymous reads and writes for the test. The new side was a FosterStack Cache 0.2.1 server with no password. I did not test Maven in this run; for Maven see the setup page.

+ +

The checklist

+

1. Find every place your builds point at the old node

+

Search your repositories and CI settings for HttpBuildCache and for the node's address. The old node's address ends in /cache/. Write the list down: each entry is one line to change.

+ +

2. Start the new server

+

Follow your first 15 minutes. Turn on a password before other machines can reach it.

+ +

3. Change one line

+
buildCache {
+    remote<HttpBuildCache> {
+-       url = uri("http://old-node.example.com:5071/cache/")
++       url = uri("https://cache.example.com/")
+        isPush = true
+    }
+}
+

If the new server has a password, also add a credentials block. See authentication and 401 errors.

+ +

4. Expect the first build to miss

+

Nothing is copied over, and nothing needs to be: a cache holds results your builds can always rebuild. The first build on the new server runs everything and fills it; the second hits.

+ +

5. Decide about the /cache/ part of the URL

+

You can drop it, as above, or keep it. FosterStack Cache uses the whole request path as the key, so a prefix works. Entries under different paths are separate, so pick one and use it everywhere.

+ +

6. Keep the old node running until you are sure

+

Rolling back is the same one-line change in reverse.

+ +

7. Retire the old node, and check the new one

+

When builds are hitting the new server, stop the old node. Watch the new one with monitoring, and size its disk with server sizing.

+ +

What happened in the run

+

Each row is two builds of the same project with gradle clean in between and Gradle's own local cache turned off, so a hit can only come from the server:

+ + + + + + +
Pointed atFirst buildSecond build
The old node, URL ending in /cache/:compileJava ran:compileJava FROM-CACHE
FosterStack Cache, only the URL changed (no /cache/):compileJava ran: the new server was empty:compileJava FROM-CACHE
FosterStack Cache, URL kept with /cache/:compileJava ran: separate entries:compileJava FROM-CACHE
Back to the old node (rollback):compileJava FROM-CACHE: its entries were still therenot run
+

The new server's status page agreed: two entries stored, two hits, two misses. One small difference you may notice: a successful upload returns 200 from the old node and 201 from FosterStack Cache. Gradle accepted both.

+ +

Related: questions and answers and what to check in a replacement build cache.

+
+ Gradle and Maven setup + FosterStack home +
+
+ + + diff --git a/sitemap.xml b/sitemap.xml index 315bc16..b00311b 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -81,4 +81,8 @@ https://fosterstack.com/first-15-minutes-build-cache/ 2026-10-08 + + https://fosterstack.com/build-cache-node-migration-checklist/ + 2026-10-08 + From a5c9577a5efb6c1a2d76ed5beddbae0079df789f Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:50:35 -0400 Subject: [PATCH 02/25] Migration page: describe the old node image only as what was observed --- build-cache-node-migration-checklist/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-cache-node-migration-checklist/index.html b/build-cache-node-migration-checklist/index.html index 2b82d4a..1c9feba 100644 --- a/build-cache-node-migration-checklist/index.html +++ b/build-cache-node-migration-checklist/index.html @@ -90,7 +90,7 @@

Build Cache Node migration checklist for Gradle and Maven

A short checklist for moving builds from the free Develocity Build Cache Node to FosterStack Cache, with a real run to back it: the same project built against an old node, then against FosterStack Cache 0.2.1, then back. Read why the node is going away in Build Cache Node end of life.

What I tested

-

Gradle 9.8.0 and a small Java project, built twice against each server. The old side was the final Build Cache Node image (version 21.2), run locally and set up to allow anonymous reads and writes for the test. The new side was a FosterStack Cache 0.2.1 server with no password. I did not test Maven in this run; for Maven see the setup page.

+

Gradle 9.8.0 and a small Java project, built twice against each server. The old side was the latest published Build Cache Node image (version 21.2, built June 2025), run locally and set up to allow anonymous reads and writes for the test. The new side was a FosterStack Cache 0.2.1 server with no password. I did not test Maven in this run; for Maven see the setup page.

The checklist

1. Find every place your builds point at the old node

From d4181d439c4a1f81b4bf0fc167ce580d5b6ce48a Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:52:11 -0400 Subject: [PATCH 03/25] Page: production build cache with Docker Compose, nginx and TLS (real run) --- .../index.html | 186 ++++++++++++++++++ sitemap.xml | 4 + 2 files changed, 190 insertions(+) create mode 100644 build-cache-docker-compose-production/index.html diff --git a/build-cache-docker-compose-production/index.html b/build-cache-docker-compose-production/index.html new file mode 100644 index 0000000..56aaf26 --- /dev/null +++ b/build-cache-docker-compose-production/index.html @@ -0,0 +1,186 @@ + + + + + +Production build cache: Docker Compose, nginx and TLS + + + + + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Production build cache: Docker Compose, nginx and TLS

+

A Compose file for a shared build cache that only answers over HTTPS, with a password, behind nginx. Everything here was run for real on FosterStack Cache 0.2.1 and Gradle 9.8.0. The exact list of what was and was not tested is just below.

+ +

What was tested, and what was not

+ + +

compose.yaml

+
services:
+  fscache:
+    image: ghcr.io/fosterstack/cache:0.2.1
+    restart: unless-stopped
+    # no "ports:" on purpose: only the proxy is reachable from outside
+    volumes:
+      - fscache-data:/home/nonroot
+    environment:
+      FSCACHE_MAX_BYTES: "53687091200"      # 50 GiB
+      FSCACHE_USERNAME: gradle
+      FSCACHE_PASSWORD: change-me           # generate one: openssl rand -base64 24
+
+  proxy:
+    image: nginx:1.29-alpine
+    restart: unless-stopped
+    depends_on:
+      - fscache
+    ports:
+      - "127.0.0.1:8443:443"                # use 443:443 on a real server
+    volumes:
+      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
+      - ./certs:/etc/nginx/certs:ro
+
+volumes:
+  fscache-data:
+

The cache has no ports entry on purpose. Only the proxy is reachable from outside. Generate a real password, for example with openssl rand -base64 24, and never keep change-me.

+ +

nginx.conf

+
server {
+    listen 443 ssl;
+    server_name localhost;
+    ssl_certificate     /etc/nginx/certs/cert.pem;
+    ssl_certificate_key /etc/nginx/certs/key.pem;
+
+    # the cache accepts entries up to 1 GiB by default; nginx's own default is 1 MB
+    client_max_body_size 1g;
+
+    location / {
+        proxy_pass http://fscache:8080;
+        proxy_set_header Host $host;
+    }
+}
+

The client_max_body_size line matters. See the tests below.

+ +

What I checked, and what I saw

+ + + + + + + + + + +
CheckResult
https://localhost:8443/healthz with the certificate trustedok
The same without trusting the certificatecurl refuses (exit 60)
The cache's own port 8080 from the hostnot reachable
Plain http:// to the proxy's TLS port400
Upload with no password, through the proxy401
Upload with the password, through the proxy201, and the value reads back
Uploads of about 1 MB and 2 MB with nginx's default settings413 from nginx, not from the cache
The same uploads, and 20 MB, after client_max_body_size 1g;201 each
+

The size limit is the one to watch. nginx rejects bodies over 1 MB by default, and Gradle's cache entries are often bigger, so builds would quietly stop storing. The cache itself accepts up to 1 GiB per entry by default, which is why the example uses 1g. See the status codes page.

+ +

Gradle over HTTPS

+

With a certificate Gradle does not trust, the build still succeeds, but without the cache, and prints:

+
Could not load entry d4f2de693b561fd2c5d60b17a9232d6f from remote build cache: (certificate_unknown) PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
+

For my self-signed test certificate I made Java trust it:

+
keytool -importcert -alias fsprod -file certs/cert.pem -keystore trust.jks -storepass changeit -noprompt
+
+# gradle.properties
+systemProp.javax.net.ssl.trustStore=/path/to/trust.jks
+systemProp.javax.net.ssl.trustStorePassword=changeit
+

With that, the first build ran :compileJava and the second printed > Task :compileJava FROM-CACHE, through the proxy, over HTTPS, with the password. With a certificate from an authority Java already trusts you would not need the truststore lines. The Gradle side of the setup is in the tutorial.

+ +

Related: Gradle build cache with Docker Compose and who can write to a remote build cache.

+
+ Your first 15 minutes + FosterStack home +
+
+ + + diff --git a/sitemap.xml b/sitemap.xml index b00311b..072da52 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -85,4 +85,8 @@ https://fosterstack.com/build-cache-node-migration-checklist/ 2026-10-08 + + https://fosterstack.com/build-cache-docker-compose-production/ + 2026-10-08 + From a1786ce4d775d7113f01b0373c799523f2ad490d Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:55:39 -0400 Subject: [PATCH 04/25] Page: Gradle build cache behind a corporate proxy (real run) --- gradle-build-cache-corporate-proxy/index.html | 156 ++++++++++++++++++ sitemap.xml | 4 + 2 files changed, 160 insertions(+) create mode 100644 gradle-build-cache-corporate-proxy/index.html diff --git a/gradle-build-cache-corporate-proxy/index.html b/gradle-build-cache-corporate-proxy/index.html new file mode 100644 index 0000000..76a6fbe --- /dev/null +++ b/gradle-build-cache-corporate-proxy/index.html @@ -0,0 +1,156 @@ + + + + + +Gradle build cache behind a corporate proxy + + + + + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Gradle build cache behind a corporate proxy

+

Many companies make builds reach the network through a proxy. This page shows how Gradle's remote build cache behaves behind one: the settings, the ways it goes wrong, and how to tell a proxy problem from a cache problem. Everything was run for real with Gradle 9.8.0 and FosterStack Cache 0.2.1.

+ +

What was tested, and what was not

+ + +

The settings

+

Gradle takes proxy settings from Java system properties. Put them in ~/.gradle/gradle.properties or the project's gradle.properties, with the systemProp. prefix:

+
systemProp.http.proxyHost=proxy.example.com
+systemProp.http.proxyPort=3128
+systemProp.https.proxyHost=proxy.example.com
+systemProp.https.proxyPort=3128
+systemProp.http.nonProxyHosts=cache.internal.example.com|localhost|127.*
+

Use the https pair for an https:// cache URL and the http pair for plain HTTP. The last line lists hosts that should skip the proxy.

+ +

What I saw

+ + + + + + + + +
SetupResultWhat the proxy logged
No proxy settings, cache at <cache-ip>second build FROM-CACHEnothing (not used)
Proxy settings, same plain-HTTP URLFROM-CACHEGET http://<cache-ip>:18130/<key>, one per build
Proxy settings, but the cache URL is 127.0.0.1FROM-CACHEnothing: Java skips the proxy for loopback addresses
Proxy settings, with the cache's address in nonProxyHostsFROM-CACHEnothing
Proxy settings, HTTPS cache, tunnel allowedFROM-CACHECONNECT <cache-ip>:18443, one per build
Proxy settings, proxy refuses the cache hostthe build succeeds but without the cache (log below)the request, answered 403
+ +

Three things that catch people

+

1. Testing against localhost skips the proxy. With the cache at 127.0.0.1, Gradle never used the proxy even though it was configured. A setup that works on your laptop against a local cache proves nothing about the proxy path. Test against the real address.

+

2. A refusing proxy looks like a cache error. When the proxy answered 403 to the tunnel, Gradle printed:

+
Could not load entry d4f2de693b561fd2c5d60b17a9232d6f from remote build cache: Loading entry from 'https://<cache-ip>:18443/d4f2de693b561fd2c5d60b17a9232d6f' response status 403: Forbidden
+

That is the same text you would get from the cache refusing a write with the read-only login (see the status codes). To tell them apart, take Gradle out of it and try the same route with curl:

+
$ curl -sS -x http://proxy.example.com:3128 https://cache.example.com/healthz
+curl: (56) CONNECT tunnel failed, response 403      # the proxy said no
+$ curl -sS --noproxy '*' https://cache.example.com/healthz
+ok                                                   # the cache is fine
+

Those two lines are what I got from my test proxy refusing, and the cache answering directly. If the first one fails and the second works, ask whoever runs the proxy to allow the cache's host and port.

+

3. Over HTTPS the proxy only sees the address. A CONNECT tunnel shows the proxy the host and port and nothing inside, so the password and the cache keys are not visible to it. That also means any rule on the path cannot be applied. This is how the proxy log looked in my run: one CONNECT line per build.

+ +

Checklist

+ + +

Related: Gradle remote build cache not working and authentication and 401 errors.

+
+ Gradle build cache tutorial + FosterStack home +
+
+ + + diff --git a/sitemap.xml b/sitemap.xml index 072da52..0735c4b 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -89,4 +89,8 @@ https://fosterstack.com/build-cache-docker-compose-production/ 2026-10-08 + + https://fosterstack.com/gradle-build-cache-corporate-proxy/ + 2026-10-08 + From 66db511664dc22258544b87225480b049626eda1 Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:58:32 -0400 Subject: [PATCH 05/25] Page: upgrade a self-hosted build cache and keep its data (real run, 0.1.0 to 0.2.1) --- sitemap.xml | 4 + upgrade-build-cache-server/index.html | 143 ++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 upgrade-build-cache-server/index.html diff --git a/sitemap.xml b/sitemap.xml index 0735c4b..e470de5 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -93,4 +93,8 @@ https://fosterstack.com/gradle-build-cache-corporate-proxy/ 2026-10-08 + + https://fosterstack.com/upgrade-build-cache-server/ + 2026-10-08 + diff --git a/upgrade-build-cache-server/index.html b/upgrade-build-cache-server/index.html new file mode 100644 index 0000000..81e0cc3 --- /dev/null +++ b/upgrade-build-cache-server/index.html @@ -0,0 +1,143 @@ + + + + + +Upgrade a self-hosted build cache and keep its data + + + + + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Upgrade a self-hosted build cache and keep its data

+

Can you move a build cache to a newer release without losing what it holds? I tested it with the only two published releases: I stored entries on 0.1.0, started 0.2.1 on the same data volume, wrote more, and then went back to 0.1.0. This is what happened.

+ +

What was tested, and what was not

+ + +

The upgrade

+
docker pull ghcr.io/fosterstack/cache:0.2.1
+docker stop fscache && docker rm fscache
+docker run -d --name fscache -p 127.0.0.1:8080:8080 \
+  -v fscache-data:/home/nonroot \
+  ghcr.io/fosterstack/cache:0.2.1
+

Use the same volume and the same settings (environment variables for the password and size cap) as the old container. Pin the exact tag you want, as above, rather than latest.

+ +

What I saw

+ + + + + + +
StepResult
0.1.0: store three entries on a fresh volume201 each; its metrics show 3 entries, 29 bytes
Stop 0.1.0, start 0.2.1 on the same volumestarts normally; the status page shows "version": "v0.2.1", 3 entries, 29 bytes; all three values read back
0.2.1: store two more201 each; 5 entries
Stop 0.2.1, start 0.1.0 on the same volume againstarts normally; all values read back, including the two written by 0.2.1
+

So in my test an upgrade lost nothing, and a rollback to the old release could still read what the new one had written.

+ +

Two differences between the releases

+ + +

If you want a safety net

+

A build cache can always be rebuilt, so losing one costs time, not work. If refilling would hurt, take a copy first: back up and restore a build cache. To start clean instead: clear or reset a build cache.

+ +

Related: how releases are built and signed and verify a release yourself, so you can check the new version before you run it.

+
+ Changelog + FosterStack home +
+
+ + + From 9b774a85db81d730de9a8ba601fbac3c58aaa8f9 Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:00:37 -0400 Subject: [PATCH 06/25] Page: build cache server exits with permission denied on its data directory (real run) --- .../index.html | 137 ++++++++++++++++++ sitemap.xml | 4 + 2 files changed, 141 insertions(+) create mode 100644 build-cache-permission-denied-data-directory/index.html diff --git a/build-cache-permission-denied-data-directory/index.html b/build-cache-permission-denied-data-directory/index.html new file mode 100644 index 0000000..0817d67 --- /dev/null +++ b/build-cache-permission-denied-data-directory/index.html @@ -0,0 +1,137 @@ + + + + + +Build cache server exits: permission denied on its data + + + + + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Build cache server exits with "permission denied" on its data

+

You start the container and it stops straight away. The log ends with permission denied. The cause is almost always who owns the data directory. This page shows the failure and the fix, reproduced for real with the 0.2.1 image.

+ +

What you see

+

The container exits with status 1 within a few seconds, and its log says:

+
{{"level":"ERROR","msg":"fscache: fatal","error":"write shutdown marker: open data/.unclean-shutdown: permission denied"}}
+ +

Why it happens

+

The server does not run as root. It runs as user 65532, and it keeps its files in a data folder inside /home/nonroot. If that folder already exists and belongs to someone else, the server cannot write to it and stops.

+

I reproduced it with a volume that already held data owned by root, which is what you get after restoring a backup as root:

+
/d       0:0   drwxr-xr-x      (owner:group, mode)
+/d/data  0:0   drwxr-xr-x
+ +

The fix

+

Give the whole tree to user 65532. The image has no shell, so use a throwaway container to do it:

+
docker run --rm -v fscache-data:/d ubuntu:24.04 chown -R 65532:65532 /d
+

After that the same image started normally: /healthz answered ok and an upload returned 201.

+ +

What does not cause it

+ + +

What I did not test

+ + +

Related: back up and restore a build cache, where the restore step has to keep the ownership, and Gradle build cache with Docker Compose.

+
+ Your first 15 minutes + FosterStack home +
+
+ + + diff --git a/sitemap.xml b/sitemap.xml index e470de5..bc60bec 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -97,4 +97,8 @@ https://fosterstack.com/upgrade-build-cache-server/ 2026-10-08 + + https://fosterstack.com/build-cache-permission-denied-data-directory/ + 2026-10-08 + From 3d21c2175cf3cd301d50d4b8a5ba456a77ef4742 Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:06:49 -0400 Subject: [PATCH 07/25] Page: Maven build cache extension behind a corporate proxy (real run) --- maven-build-cache-corporate-proxy/index.html | 171 +++++++++++++++++++ sitemap.xml | 4 + 2 files changed, 175 insertions(+) create mode 100644 maven-build-cache-corporate-proxy/index.html diff --git a/maven-build-cache-corporate-proxy/index.html b/maven-build-cache-corporate-proxy/index.html new file mode 100644 index 0000000..836e949 --- /dev/null +++ b/maven-build-cache-corporate-proxy/index.html @@ -0,0 +1,171 @@ + + + + + +Maven build cache extension behind a corporate proxy + + + + + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Maven build cache extension behind a corporate proxy

+

Maven has its own proxy settings, and the Apache Build Cache Extension follows them. This page shows what the extension sent through a proxy, how it differs from Gradle, and what you see when the proxy says no. All of it was run for real with Maven 3.10.0, extension 1.2.3 and FosterStack Cache 0.2.1.

+ +

What was tested, and what was not

+ + +

The settings

+

In ~/.m2/settings.xml, next to the cache's password entry:

+
<settings>
+  <servers>
+    <server>
+      <id>fosterstack-cache</id>
+      <username>maven</username>
+      <password>your-password-or-CI-secret</password>
+    </server>
+  </servers>
+  <proxies>
+    <proxy>
+      <id>corp</id>
+      <active>true</active>
+      <protocol>http</protocol>
+      <host>proxy.example.com</host>
+      <port>3128</port>
+      <nonProxyHosts>cache.internal.example.com|localhost</nonProxyHosts>
+    </proxy>
+  </proxies>
+</settings>
+

The cache itself is set up as in the Maven setup. The server id must still match the remote's.

+ +

What went through the proxy

+

With the proxy configured, all of the extension's cache traffic went through it: the lookups, the download that restores a build, and the uploads. This is what the proxy logged for two builds, the first filling the cache and the second restoring from it (the proxy also saw Maven's own traffic to repository.apache.org, which is not shown):

+
build 1 (stores)
+GET http://<cache-ip>:18132/v1.1/demo/demo/<id>/buildinfo.xml
+GET http://<cache-ip>:18132/v1.1/demo/demo/<id>/buildinfo.xml
+PUT http://<cache-ip>:18132/v1.1/demo/demo/<id>/demo.jar
+PUT http://<cache-ip>:18132/v1.1/demo/demo/<id>/buildinfo.xml
+PUT http://<cache-ip>:18132/v1.1/demo/demo/<id>/build-cache-report.xml
+
+build 2 (restores)
+GET http://<cache-ip>:18132/v1.1/demo/demo/<id>/buildinfo.xml
+GET http://<cache-ip>:18132/v1.1/demo/demo/<id>/buildinfo.xml
+GET http://<cache-ip>:18132/v1.1/demo/demo/<id>/demo.jar
+PUT http://<cache-ip>:18132/v1.1/demo/demo/<id>/build-cache-report.xml
+

The second build logged Found cached build, restoring demo:demo from cache.

+ +

Other cases

+ + + + + + +
SetupWhat happened
Proxy configured, cache URL is 127.0.0.1Still went through the proxy (4 requests logged). Unlike Gradle, Maven does not skip the proxy for loopback.
Proxy configured, the cache's address in <nonProxyHosts>Nothing reached the proxy. The build still restored from the cache.
Proxy refuses the cache's hostNothing was stored or restored. The build succeeded without the cache, and logged Error downloading cache item with HTTP Status: 403.
Proxy not running at allThe lookup failed with Connect to 127.0.0.1:18131 ... Connection refused. The build succeeded without the cache, and nothing was uploaded.
+ +

Things that catch people

+ + +

Related: Maven build cache extension not restoring and a production setup with TLS.

+
+ Maven remote cache setup + FosterStack home +
+
+ + + diff --git a/sitemap.xml b/sitemap.xml index bc60bec..bb5703c 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -101,4 +101,8 @@ https://fosterstack.com/build-cache-permission-denied-data-directory/ 2026-10-08 + + https://fosterstack.com/maven-build-cache-corporate-proxy/ + 2026-10-08 + From a160ae0f8443e6e0b96c134004ffc03caa77a9d3 Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:07:07 -0400 Subject: [PATCH 08/25] Gradle proxy page: Maven proxy settings now have their own tested page --- gradle-build-cache-corporate-proxy/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle-build-cache-corporate-proxy/index.html b/gradle-build-cache-corporate-proxy/index.html index 76a6fbe..2a21e69 100644 --- a/gradle-build-cache-corporate-proxy/index.html +++ b/gradle-build-cache-corporate-proxy/index.html @@ -92,7 +92,7 @@

Gradle build cache behind a corporate proxy

What was tested, and what was not

The settings

From bb9fc4282f42872e3fb265951f04e9dfc344caa7 Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:09:41 -0400 Subject: [PATCH 09/25] Review fixes on the migration pages: brace artifact, over-claims, vendor wording, title scope --- build-cache-docker-compose-production/index.html | 2 +- build-cache-node-migration-checklist/index.html | 12 ++++++------ .../index.html | 4 ++-- gradle-build-cache-corporate-proxy/index.html | 5 +++-- maven-build-cache-corporate-proxy/index.html | 2 +- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/build-cache-docker-compose-production/index.html b/build-cache-docker-compose-production/index.html index 56aaf26..e711a06 100644 --- a/build-cache-docker-compose-production/index.html +++ b/build-cache-docker-compose-production/index.html @@ -152,7 +152,7 @@

What I checked, and what I saw

Uploads of about 1 MB and 2 MB with nginx's default settings413 from nginx, not from the cache The same uploads, and 20 MB, after client_max_body_size 1g;201 each -

The size limit is the one to watch. nginx rejects bodies over 1 MB by default, and Gradle's cache entries are often bigger, so builds would quietly stop storing. The cache itself accepts up to 1 GiB per entry by default, which is why the example uses 1g. See the status codes page.

+

The size limit is the one to watch. nginx rejects bodies over 1 MB by default, and Gradle's cache entries are often bigger, so uploads of larger entries would be refused. The cache itself accepts up to 1 GiB per entry by default, which is why the example uses 1g. See the status codes page.

Gradle over HTTPS

With a certificate Gradle does not trust, the build still succeeds, but without the cache, and prints:

diff --git a/build-cache-node-migration-checklist/index.html b/build-cache-node-migration-checklist/index.html index 1c9feba..b0660ab 100644 --- a/build-cache-node-migration-checklist/index.html +++ b/build-cache-node-migration-checklist/index.html @@ -3,9 +3,9 @@ -Build Cache Node migration checklist for Gradle and Maven +Build Cache Node migration checklist for Gradle - + @@ -86,11 +86,11 @@
Infrastructure worth keeping — and kept patched.
-

Build Cache Node migration checklist for Gradle and Maven

-

A short checklist for moving builds from the free Develocity Build Cache Node to FosterStack Cache, with a real run to back it: the same project built against an old node, then against FosterStack Cache 0.2.1, then back. Read why the node is going away in Build Cache Node end of life.

+

Build Cache Node migration checklist for Gradle

+

A short checklist for moving builds from the Develocity Build Cache Node to FosterStack Cache, with a real run to back it: the same project built against an old node, then against FosterStack Cache 0.2.1, then back. Read why the node is going away in Build Cache Node end of life.

What I tested

-

Gradle 9.8.0 and a small Java project, built twice against each server. The old side was the latest published Build Cache Node image (version 21.2, built June 2025), run locally and set up to allow anonymous reads and writes for the test. The new side was a FosterStack Cache 0.2.1 server with no password. I did not test Maven in this run; for Maven see the setup page.

+

Gradle 9.8.0 and a small Java project, built twice against each server. The old side was a Build Cache Node image (version 21.2, built June 2025), run locally and set up to allow anonymous reads and writes for the test. The new side was a FosterStack Cache 0.2.1 server with no password. I did not test Maven in this run; for Maven see the setup page.

The checklist

1. Find every place your builds point at the old node

@@ -116,7 +116,7 @@

5. Decide about the /cache/ part of the URL

You can drop it, as above, or keep it. FosterStack Cache uses the whole request path as the key, so a prefix works. Entries under different paths are separate, so pick one and use it everywhere.

6. Keep the old node running until you are sure

-

Rolling back is the same one-line change in reverse.

+

With Gradle, rolling back is the same one-line change in reverse; I tested that.

7. Retire the old node, and check the new one

When builds are hitting the new server, stop the old node. Watch the new one with monitoring, and size its disk with server sizing.

diff --git a/build-cache-permission-denied-data-directory/index.html b/build-cache-permission-denied-data-directory/index.html index 0817d67..9723d3f 100644 --- a/build-cache-permission-denied-data-directory/index.html +++ b/build-cache-permission-denied-data-directory/index.html @@ -87,11 +87,11 @@

Build cache server exits with "permission denied" on its data

-

You start the container and it stops straight away. The log ends with permission denied. The cause is almost always who owns the data directory. This page shows the failure and the fix, reproduced for real with the 0.2.1 image.

+

You start the container and it stops straight away. The log ends with permission denied. The cause I reproduced is who owns the data directory. This page shows the failure and the fix, reproduced for real with the 0.2.1 image.

What you see

The container exits with status 1 within a few seconds, and its log says:

-
{{"level":"ERROR","msg":"fscache: fatal","error":"write shutdown marker: open data/.unclean-shutdown: permission denied"}}
+
{"level":"ERROR","msg":"fscache: fatal","error":"write shutdown marker: open data/.unclean-shutdown: permission denied"}

Why it happens

The server does not run as root. It runs as user 65532, and it keeps its files in a data folder inside /home/nonroot. If that folder already exists and belongs to someone else, the server cannot write to it and stops.

diff --git a/gradle-build-cache-corporate-proxy/index.html b/gradle-build-cache-corporate-proxy/index.html index 2a21e69..af5a242 100644 --- a/gradle-build-cache-corporate-proxy/index.html +++ b/gradle-build-cache-corporate-proxy/index.html @@ -105,6 +105,7 @@

The settings

Use the https pair for an https:// cache URL and the http pair for plain HTTP. The last line lists hosts that should skip the proxy.

What I saw

+

For the plain-HTTP rows I set isAllowInsecureProtocol = true, which Gradle requires for non-loopback http://. That is for the test rig only; use HTTPS for real.

@@ -124,12 +125,12 @@

Three things that catch people

curl: (56) CONNECT tunnel failed, response 403 # the proxy said no $ curl -sS --noproxy '*' https://cache.example.com/healthz ok # the cache is fine -

Those two lines are what I got from my test proxy refusing, and the cache answering directly. If the first one fails and the second works, ask whoever runs the proxy to allow the cache's host and port.

+

I substituted example hostnames in those commands; the two output lines are the real ones, from my test proxy refusing and from the cache answering directly. If the first one fails and the second works, ask whoever runs the proxy to allow the cache's host and port.

3. Over HTTPS the proxy only sees the address. A CONNECT tunnel shows the proxy the host and port and nothing inside, so the password and the cache keys are not visible to it. That also means any rule on the path cannot be applied. This is how the proxy log looked in my run: one CONNECT line per build.

Checklist

It is early. The paid tiers are not built. Everything you can download today is the free core.

-

Security is the difference

+

More than kept patched.

Security is what we are building this product around. The release items below ship in the current release. The supply-chain items describe how we work today, in our own build and CI. You can check each one yourself.

Signed and verifiable

SetupResultWhat the proxy logged
No proxy settings, cache at <cache-ip>second build FROM-CACHEnothing (not used)
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StageWhat it does in v0.2.1NIST practiceHow much
Source admissionChecks the tag and commit signatures against the allowed signers on main (or GitHub's own key for merges made on github.com), that the commit is on main, that every required check passed, and that an approved requirements list exists for the version. Signs.800-204D 5.1.4 "Secure Code Commits" (commit-signing enforcement, COMMIT-REQ-2); 5.1.2 "Secure Pull-Push Operations on Repositories" (automated checks before code is trusted)Partly. There is one maintainer, so no second person reviews or approves a change.
BuildBuilds the binaries once on a fresh GitHub-hosted machine with the toolchain pinned, and checks that the dependency files did not change. Writes checksums and an SBOM per archive. Signs a build statement and SLSA provenance.800-204D 5.1.1 "Secure Build"; 800-204C 3.3.3 (SBOM generation and code signing as pipeline tasks)Partly. The signing runs in the same job as the build steps, not in a separate, more trusted process as 5.1.1 asks.
Image assemblyChecks every archive against the signed build statement before opening it. Adds one binary to a base image pinned by digest, with no commands run inside the image. Pushes by digest only, with no version tags, to a separate candidates package. Signs, recording the base digest and binary hashes, plus SLSA provenance.800-204D 4.2: "The inputs and outputs of each build step should be verified"Implements the practice.
Reproducibility checkAssembles the images again on a separate machine. The digests must match, or the release fails. Signs.No direct match in either document.Not mapped. It repeats the image assembly; it does not rebuild the binaries from source.
ScansTwo different scanners with different vulnerability databases, each on its own machine, check the image-assembly and reproducibility statements first, then scan every platform image of every variant. One also scans the archives, the other the Go module files. A finding at any severity blocks the release. For one of the two scanners, a published VEX statement can clear a finding. Each scanner signs its verdict for the exact digests.800-204C 4.8 and 4.8.1 (container image scans, software composition analysis); 800-204D 5.2, DEPLOY_REQ-4 (evidence the image was scanned)Partly. The release pipeline runs no static or dynamic application security testing itself. The signed verdict records a pass, not the full scan report.
Acceptance testsRun against the exact candidate images: the Linux archives and binaries (macOS archives are checked, not run), the images on both CPU architectures, the image variants, the Gradle and Maven test suites, the Kubernetes setup from our docs, and a test that the server makes no outbound connections. A final job checks that every release-blocking acceptance test in the approved list passed, then signs one statement.800-204C 3.3.1 (tests run in the pipeline; an organisation may choose to stop the pipeline when a test fails); 4.6 (stop further pipeline tasks if a test fails)Implements the practice. The individual test jobs do not sign their own results.
Release authorizationVerifies every earlier statement: signed by the expected stage, naming this commit and these digests, scan verdicts pass, acceptance results complete. Anything missing stops the release. Records each statement's transparency-log entry. Signs the approval.800-204C 4.6: "verify that all required signatures … are present to ensure that no one bypasses the pipeline"; 800-204D 5.1.1 (policy checks that the required attestations are found)Partly. The policy is code in the workflow file, not a separate signed policy document as 5.1.1 describes.
Promotion and publishWill not start without the signed approval. Copies the approved digests to GitHub's registry and the Docker Hub mirror with no rebuild, and checks the digests did not change. Signs the public images and the checksums file. Uploads the release files, re-checks the image signatures, the signed checksums and every tag the way a customer would, without credentials, then makes the release page public.800-204D 5.2, DEPLOY-REQ-3 (only an artifact made by the approved build process is cleared)Partly. We refuse to publish anything the approved build did not make. Enforcing that at deploy time is up to your own environment.
Signing, across all stagesKeyless signing: a short-lived signing identity per stage, no stored signing key, every signature logged publicly.800-204C 4.6: "signing the release artifact during each required CI/CD stage (preferably multi-party signing)"; 800-204D 5.1.1 (attestations signed, stored tamper-proof)Partly. Not multi-party: every signature is made by automation in one repository, run by one maintainer.
Evidence bundlerelease-manifest.json lists every digest, the hash of the approved requirements list, per-test results, and the transparency-log entry of every statement the authorization verified. It is attached to each image in GitHub's registry as a signed statement and published with the SBOMs, VEX file and signed checksums.800-204D 4 (record provenance, with a way to verify and authenticate it)Implements the practice. Neither document has a section on VEX.
+ +

Which SLSA Build level does v0.2.1 meet?

+

We assess v0.2.1 at SLSA Build Level 2, against the SLSA v1.0 requirements. We do not claim Build Level 3.

+
    +
  • Consistent build process (L1). Met. One workflow builds every release. Only a tag push starts it, and the first stage stops any tag not signed by an allowed signer.
  • +
  • Provenance exists (L1). Met. SLSA v1 provenance covers the binary archives, their SBOMs, checksums.txt, and the three container images, naming each digest, the source commit and the workflow run.
  • +
  • Provenance is distributed (L1). Met. It is in GitHub's attestation store, and gh attestation verify returns it.
  • +
  • Hosted build platform (L2). Met. The build ran on GitHub-hosted machines, never on a workstation.
  • +
  • Authentic provenance (L2). Met. The provenance is signed with an identity issued to the building workflow, and you can check the signature and the signing workflow yourself.
  • +
+

Level 3 is not claimed. It requires that the build steps cannot reach the material used to sign the provenance ("Unforgeable" and "Isolated" in the spec). In v0.2.1, the build steps and the provenance signing run in the same job, so that separation is not there. Build Level 3 is planned. It is not part of v0.2.1.

+ +

What is not met today

+
    +
  • SLSA Build Level 3.
  • +
  • Evidence made by a separate, more trusted process than the build itself (800-204D 5.1.1).
  • +
  • A second person: one maintainer writes, approves and releases every change. No review by another person, and no multi-party signing (800-204D 5.1.2, 800-204C 4.6).
  • +
  • All signing is automated. 800-204D 5.1.3, written for software update systems, points the same way: multiple keys with threshold trust, and automated keys kept out of roles customers rely on.
  • +
  • The acceptance test jobs pass their results to the job that signs them; they do not sign or verify statements themselves.
  • +
  • No dynamic application security testing, and no static testing inside the release pipeline.
  • +
  • The release policy is workflow code, not a separate signed policy document.
  • +
  • The reproducibility check covers image assembly, not a second build of the binaries from source.
  • +
  • Promoted images are in the public registries a few steps before the final customer-style checks finish. Only the release page waits for those checks.
  • +
  • Scan verdicts are signed, but the full scan reports are not published as release files.
  • +
  • Checking provenance with gh attestation verify needs a GitHub token. The cosign checks need no account.
  • +
+ +

Check it yourself

+

This checks the SLSA provenance of the v0.2.1 image and the workflow that signed it:

+
$ gh attestation verify oci://ghcr.io/fosterstack/cache:0.2.1 \
+  --repo fosterstack/cache \
+  --signer-workflow fosterstack/cache/.github/workflows/stage-image.yml
+ + +

Section numbers refer to the final published versions of NIST SP 800-204C and NIST SP 800-204D. Guidance we designed against, not a certification we hold.

+ + +
+ + + + diff --git a/index.html b/index.html index ce8d647..e08f8b0 100644 --- a/index.html +++ b/index.html @@ -136,7 +136,7 @@

What you get that a bare HTTP endpoint doesn't give you

Authentication

HTTP Basic Auth over TLS, wired the way Gradle and Maven already expect credentials. Constant-time comparison, no credential ever logged.

Metrics & status

Prometheus metrics, a read-only status page with hit rates and size-vs-cap, and a Grafana dashboard in the repo — so you know the cache is earning its keep.

Maintenance target

Vulnerable dependencies fixed fast — our target is 48 hours from disclosure.

-

Acceptance-tested

Every change runs against a real multi-module Gradle build in CI: a from-scratch second build must produce real remote-cache hits, or the change does not merge.

+

Designed against NIST guidance

Our release pipeline is designed against NIST SP 800-204C and 800-204D: each stage's result is recorded in a signed statement, and nothing is promoted to the public registries until a signed release authorization has verified every earlier statement. Guidance we designed against, not a certification we hold. See how each stage maps.

30-minute migration

A step-by-step guide for existing Build Cache Node deployments. Same protocol, same CI config shape — migrating is mostly a URL change.

diff --git a/sitemap.xml b/sitemap.xml index 628b2a9..88e92da 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -95,6 +95,10 @@ https://fosterstack.com/gradle-remote-cache-not-working/ 2026-10-08 + + https://fosterstack.com/how-our-pipeline-maps-to-nist-and-slsa/ + 2026-10-08 + https://fosterstack.com/how-we-build/ 2026-10-08 From 82b595db1511eb3ea5b15bc1c77e42a5b785346a Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:46:12 -0400 Subject: [PATCH 16/25] NIST page: SLSA Build track runs Level 1 to 3 --- how-our-pipeline-maps-to-nist-and-slsa/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/how-our-pipeline-maps-to-nist-and-slsa/index.html b/how-our-pipeline-maps-to-nist-and-slsa/index.html index eb135c4..721c7ff 100644 --- a/how-our-pipeline-maps-to-nist-and-slsa/index.html +++ b/how-our-pipeline-maps-to-nist-and-slsa/index.html @@ -88,7 +88,7 @@

How our release pipeline maps to NIST SP 800-204C, 800-204D and SLSA

This page takes the release pipeline that built FosterStack Cache v0.2.1 and puts each stage next to the NIST practice it maps to. It says where a stage only partly implements a practice, and it says which SLSA Build level we assess v0.2.1 at.

-

Words used here: SBOM is a parts list of what is inside a build. VEX is a note saying a known flaw does not affect us. Provenance is a signed record of how and where something was built. Digest is the fingerprint of a file or image. SLSA is a public scale of how well a build can be trusted, from Level 1 to Level 4 in its Build track.

+

Words used here: SBOM is a parts list of what is inside a build. VEX is a note saying a known flaw does not affect us. Provenance is a signed record of how and where something was built. Digest is the fingerprint of a file or image. SLSA is a public scale of how well a build can be trusted, from Level 1 to Level 3 in its Build track.

Guidance we designed against, not a certification we hold.

The two NIST documents

From 627781311a3213984eaf93c394cec2caa99d397a Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:47:23 -0400 Subject: [PATCH 17/25] FAQ: two different scanners, not independent (their databases overlap) --- build-cache-node-faq/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build-cache-node-faq/index.html b/build-cache-node-faq/index.html index 194177f..35bff9e 100644 --- a/build-cache-node-faq/index.html +++ b/build-cache-node-faq/index.html @@ -14,7 +14,7 @@ - + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Gradle build cache for a monorepo: what a multi-module run shows

+

A monorepo is many Gradle modules in one build. A shared cache server lets CI and every developer reuse each module's work. Below is a real run with four modules against FosterStack Cache 0.2.1: which modules came from the cache, and which did not.

+ +

The short answer

+
    +
  • Change one module's code and the other modules still come from the cache.
  • +
  • Change a module's public methods and every module that uses it rebuilds.
  • +
  • A teammate who makes the same change gets everything from the cache.
  • +
+ +

What we ran

+

A build with four modules. core has no dependencies. util and api use core. app uses util and api. Each module has 12 small Java classes. The cache server was release 0.2.1 (macOS, arm64), with Gradle 9.8.0.

+

Every run below used a brand-new copy of the project and an empty Gradle home folder, and the local cache was switched off. The only thing the runs shared was the cache server. Each run was gradle build --console=plain --no-daemon, and every build finished with BUILD SUCCESSFUL.

+ +

Results: what each module's compile step did

+ + + + + + + + + + +
Runcoreutilapiapp
1. First build, as CI would do itranranranran
2. Another machine, same codefrom cachefrom cachefrom cachefrom cache
3. Edit inside a method in coreranfrom cachefrom cachefrom cache
4. A teammate makes the same editfrom cachefrom cachefrom cachefrom cache
5. Add a public method to coreranranranran
6. Edit only appfrom cachefrom cachefrom cacheran
+

"Ran" means Gradle did the work and uploaded the result. "From cache" means Gradle downloaded it instead, and printed FROM-CACHE next to the task. This is what run 3 printed:

+
> Task :core:compileJava
+> Task :util:compileJava FROM-CACHE
+> Task :api:compileJava FROM-CACHE
+> Task :app:compileJava FROM-CACHE
+ +

What this means for your monorepo

+
    +
  • Edits inside a method stay local (run 3). Gradle compares the public shape of a module, not every byte, when it decides whether a module that depends on it must rebuild. A change inside a method body left the other three modules' inputs the same, so they came from the cache.
  • +
  • Changes to public methods spread (run 5). All four modules ran. In a big monorepo, a small, stable shared module with few public changes keeps more of the build cached.
  • +
  • The first person pays, everyone else downloads (runs 4 and 6). The edit in run 3 was uploaded once. In run 4 a fresh checkout made the same edit and ran nothing.
  • +
+

Only the compile step is shown here, because it is the step that showed the pattern clearly in a small project. The jar steps in this build are not cacheable by default in Gradle, so they ran every time. The project is small, so we are not quoting time saved: the cache pays off as your modules get bigger.

+ +

Set it up

+

This is the exact settings.gradle.kts we ran. Replace the address with your server's. We used 127.0.0.1 over plain HTTP because the test ran on one machine; use HTTPS across a network.

+
buildCache {
+    local { isEnabled = false }
+    remote<HttpBuildCache> {
+        url = uri("http://127.0.0.1:18140/")
+        isPush = true
+        isAllowInsecureProtocol = true
+    }
+}
+

And this is the whole gradle.properties:

+
org.gradle.caching=true
+org.gradle.parallel=true
+

In a real team, let only CI upload (isPush) and have developers read. That setup, with a read-only login, is on the CI writes, developers read page.

+ +

If you do not see FROM-CACHE

+

Work through why the hit rate is low. To check the server itself, see your first 15 minutes, and for logins see authentication.

+ +

Run on Gradle 9.8.0 with FosterStack Cache 0.2.1 on macOS (arm64), Java 27.

+
+ + + + diff --git a/sitemap.xml b/sitemap.xml index 88e92da..e443fb4 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -167,4 +167,8 @@ https://fosterstack.com/verify-replacement-build-cache/ 2026-10-08 + + https://fosterstack.com/gradle-build-cache-monorepo/ + 2026-10-08 + From 06fc027ec2fb9d218e841c800386b1ebe4c2f48f Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:14:04 -0400 Subject: [PATCH 19/25] Page: Move a Maven build cache to a new server (real five-build run on 0.2.1) --- .../index.html | 153 ++++++++++++++++++ sitemap.xml | 4 + 2 files changed, 157 insertions(+) create mode 100644 migrate-maven-build-cache-to-a-new-server/index.html diff --git a/migrate-maven-build-cache-to-a-new-server/index.html b/migrate-maven-build-cache-to-a-new-server/index.html new file mode 100644 index 0000000..6482cd7 --- /dev/null +++ b/migrate-maven-build-cache-to-a-new-server/index.html @@ -0,0 +1,153 @@ + + + + + +Move a Maven Build Cache to a New Server: Real Run + + + + + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Move a Maven build cache to a new server

+

You are replacing the machine that runs your build cache and your builds use the Apache Maven Build Cache Extension. Here is what to change, what to expect from the first build, and how to keep the cache warm. Every step was run for real with FosterStack Cache 0.2.1.

+ +

The short answer

+
    +
  • Change the address in .mvn/maven-build-cache-config.xml. Nothing else in the project changes.
  • +
  • If you only change the address, the first build is a normal full build and fills the new server. Nothing fails.
  • +
  • If you want the new server to be ready at once, stop the old one, copy its data folder, and start the new server on the copy.
  • +
+ +

What we ran

+

A small Maven project, Maven 3.10.0, extension 1.2.3, macOS (arm64). Every build used a fresh copy of the project and an empty local build cache, so the only thing shared was the cache server. All five builds ended with BUILD SUCCESS.

+ + + + + + + + + +
BuildServer it usedResult
1Old server, emptyBuilt the project and saved the result to the server
2Old server, from another machineRestored from the cache, no compile
3New server, empty, only the address changedFull build, saved to the new server
4New server, from another machineRestored from the cache, no compile
5New server started on a copy of the old data folderRestored from the cache, no compile
+ +

Option 1: change the address only

+

Start the new server, then edit the url in the cache config and commit it. We changed this one line and nothing else:

+
<remote enabled="true" saveToRemote="true" id="fosterstack-cache">
+  <url>http://127.0.0.1:18151/</url>
+</remote>
+

Build 3 had no cached result to find, so Maven compiled the project and saved it:

+
[INFO] Attempting to restore project demo:demo from build cache
+[INFO] Compiling 1 source file with javac [debug release 17] to target/classes
+[INFO] Saved to remote cache http://127.0.0.1:18151//v1.1/demo/demo/ebb9bceba2f7a6bc/demo.jar
+

From then on, every other machine pointing at the new address gets the result from the new server, as build 4 did. Anyone who still has the old address in their config keeps using the old server until they update.

+ +

Option 2: copy the data folder first

+

This keeps the new server warm from the first build. Stop the old server, copy its data folder, and start the new server with its data folder set to the copy. See back up and restore for why the old server should be stopped first.

+
cp -R data-old data-new-copied
+FSCACHE_ADDR=127.0.0.1:18152 FSCACHE_DATA_DIR=$PWD/data-new-copied fscache
+

Build 5 found the result straight away, with no compile:

+
[INFO] Attempting to restore project demo:demo from build cache
+[INFO] Found cached build, restoring demo:demo from cache by checksum ebb9bceba2f7a6bc
+

The new server's status page agreed: 2 hits and 0 misses at that point.

+ +

How to tell it worked

+

Look for Found cached build, restoring in the Maven output. You may also see Saved to remote cache lines on a build that restored. In our run these were a small report file that the extension saves each time, not a rebuild. If a build prints Compiling instead of Found cached build, it did not find a stored result.

+ +

Credentials and other settings

+

If the new server has logins, Maven reads them from the <server> entry in settings.xml whose id matches the id on the <remote> line. See Maven remote build cache setup for that, and behind a corporate proxy if your builds go through one. To move Gradle builds too, see one server for Gradle and Maven.

+ +

Run on Maven 3.10.0 with maven-build-cache-extension 1.2.3 and FosterStack Cache 0.2.1, macOS (arm64).

+
+ + + + diff --git a/sitemap.xml b/sitemap.xml index e443fb4..32380fb 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -171,4 +171,8 @@ https://fosterstack.com/gradle-build-cache-monorepo/ 2026-10-08 + + https://fosterstack.com/migrate-maven-build-cache-to-a-new-server/ + 2026-10-08 + From 6591e0ce17af7e3a1e968e743d05ecd5e4637f44 Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:08:24 -0400 Subject: [PATCH 20/25] Page: Enable or disable the Gradle build cache (flag, property, settings; nine real runs on 0.2.1) --- gradle-build-cache-enable-disable/index.html | 155 +++++++++++++++++++ sitemap.xml | 4 + 2 files changed, 159 insertions(+) create mode 100644 gradle-build-cache-enable-disable/index.html diff --git a/gradle-build-cache-enable-disable/index.html b/gradle-build-cache-enable-disable/index.html new file mode 100644 index 0000000..edee0a7 --- /dev/null +++ b/gradle-build-cache-enable-disable/index.html @@ -0,0 +1,155 @@ + + + + + +Enable or Disable the Gradle Build Cache: Flag or Property + + + + + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Enable or disable the Gradle build cache: flag, property or settings

+

Gradle uses the build cache only when it is switched on and a cache is set up. Here is every way we tried to switch it on and off, with what each one did against a real cache server (release 0.2.1, Gradle 9.8.0).

+ +

The short answer

+
    +
  • To turn it on for one build, add --build-cache.
  • +
  • To turn it on for everyone, put org.gradle.caching=true in gradle.properties.
  • +
  • To turn it off for one build, add --no-build-cache. It wins over the property.
  • +
  • Switching it on is not enough by itself: the project also needs the cache address in settings.gradle.kts.
  • +
+ +

What we ran

+

A four-module project with the cache address set in settings.gradle.kts (shown below). Each run used a fresh copy of the project and an empty Gradle home, so the only shared thing was the cache server. We ran only :core:compileJava and looked at what Gradle printed next to it. Every build ended with BUILD SUCCESSFUL.

+ + + + + + + + + + + +
How it was switchedCommandResult
Flag (first build)gradle :core:compileJava --build-cacheRan, and saved the result to the server
Nothing setgradle :core:compileJavaRan. The cache was not used
Flaggradle :core:compileJava --build-cacheFROM-CACHE
Property in gradle.propertiesorg.gradle.caching=trueFROM-CACHE
Property, and the flag offorg.gradle.caching=true and --no-build-cacheRan. The flag won
System propertygradle :core:compileJava -Dorg.gradle.caching=trueFROM-CACHE
Flag, and rerun everything--build-cache --rerun-tasksRan, even though a result was stored
+ +

Which one to use

+
    +
  • Everyone, always: the property in gradle.properties, checked in with the project. New machines then need no flags.
  • +
  • CI only: pass --build-cache in the CI command and leave the property out.
  • +
  • A suspect build: run it with --no-build-cache to rule the cache out. If it stays broken, the cache is not the cause.
  • +
  • Force fresh work: --rerun-tasks makes Gradle redo the tasks even when a stored result exists.
  • +
+ +

The address still has to be set

+

This is the settings.gradle.kts we ran. Replace the address with your server's. We used 127.0.0.1 over plain HTTP because the test ran on one machine; use HTTPS across a network.

+
buildCache {
+    local { isEnabled = false }
+    remote<HttpBuildCache> {
+        url = uri("http://127.0.0.1:18141/")
+        isPush = true
+        isAllowInsecureProtocol = true
+    }
+}
+ +

Read-only: turn off uploading, not the cache

+

Setting isPush = false lets a build download from the cache but never upload. We tried it against an empty server with the cache switched on. The build succeeded and ran the task, and the server still held 0 entries afterwards. A second build also ran the task, because nothing had ever been stored. Read-only only helps when someone else, such as CI, fills the cache. That setup is described in CI writes, developers read.

+ +

If you do not see FROM-CACHE

+

Work through why the hit rate is low and remote cache not working. To clear stored results, see reset the build cache.

+ +

Run on Gradle 9.8.0 with FosterStack Cache 0.2.1, macOS (arm64), Java 27.

+
+ + + + diff --git a/sitemap.xml b/sitemap.xml index 32380fb..57f6f62 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -175,4 +175,8 @@ https://fosterstack.com/migrate-maven-build-cache-to-a-new-server/ 2026-10-08 + + https://fosterstack.com/gradle-build-cache-enable-disable/ + 2026-10-08 + From 2d76b7c6d1fda4b9bc4fb64c70e0e634619641a8 Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:10:06 -0400 Subject: [PATCH 21/25] Page: Enable or disable the Maven build cache extension (seven real builds on 0.2.1) --- .../index.html | 153 ++++++++++++++++++ sitemap.xml | 4 + 2 files changed, 157 insertions(+) create mode 100644 maven-build-cache-extension-enable-disable/index.html diff --git a/maven-build-cache-extension-enable-disable/index.html b/maven-build-cache-extension-enable-disable/index.html new file mode 100644 index 0000000..d5ed5a5 --- /dev/null +++ b/maven-build-cache-extension-enable-disable/index.html @@ -0,0 +1,153 @@ + + + + + +Enable or Disable the Maven Build Cache Extension + + + + + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Enable or disable the Maven build cache extension: the switches that work

+

The Apache Maven Build Cache Extension has command-line switches to turn it off, skip the lookup, stop talking to the server, or stop uploading. Here is what each one did in a real run against a self-hosted cache (FosterStack Cache 0.2.1, Maven 3.10.0, extension 1.2.3).

+ +

The short answer

+
    +
  • -Dmaven.build.cache.enabled=false turns the cache off for that build.
  • +
  • -Dmaven.build.cache.skipCache=true ignores stored results and builds fully.
  • +
  • -Dmaven.build.cache.remote.enabled=false stops all contact with the server.
  • +
  • -Dmaven.build.cache.remote.save.enabled=false lets the build download but never upload.
  • +
+ +

What we ran

+

A small Maven project with the extension set up as described in Maven remote build cache setup. Every build used a fresh copy of the project and an empty local build cache, so the only shared thing was the server. All seven builds ended with BUILD SUCCESS.

+ + + + + + + + + + + +
BuildAdded to the commandResult
1nothing (empty server)Compiled, and saved the result to the server
2nothing (another machine)Restored from the cache, no compile
3-Dmaven.build.cache.enabled=falseCompiled. The cache was off
4-Dmaven.build.cache.skipCache=trueCompiled, although a result was stored
5-Dmaven.build.cache.remote.enabled=falseCompiled. It did not ask the server at all
6-Dmaven.build.cache.remote.save.enabled=false (empty server)Compiled. The server still had 0 entries
7same as 6, againCompiled again, because nothing was ever stored
+ +

What Maven prints for each switch

+
enabled=false:
+[INFO] Cache disabled by command line flag, project will be built fully and not cached
+
+skipCache=true:
+[INFO] Project demo:demo is marked as requiring force rebuild, will skip lookup in build cache
+

With remote.enabled=false there was no download attempt in the log. With remote.save.enabled=false the build still looked in the server, found nothing, and built locally.

+ +

"Error downloading cache item" on a normal miss

+

The first build against an empty server printed this line, and it still succeeded:

+
[ERROR] Error downloading cache item: http://127.0.0.1:18160//v1.1/demo/demo/ebb9bceba2f7a6bc/buildinfo.xml
+[INFO] Remote cache is incomplete or missing, trying local build for demo:demo
+

In our run this was an ordinary miss: nothing was stored under that key yet, so Maven built the project itself. The next line says so. If you see this line on a cache that you know holds the result, check the address, the login and the proxy: see Maven build cache not restoring.

+ +

Which one to use

+
    +
  • A suspect build: enabled=false rules the cache out completely.
  • +
  • Make sure you build fresh: skipCache=true, for example for a release build.
  • +
  • Working offline or off the VPN: remote.enabled=false, so Maven does not wait on a server it cannot reach.
  • +
  • Developers read, CI writes: remote.save.enabled=false on developer machines. The same idea for Gradle is on CI writes, developers read. A read-only login on the server enforces it.
  • +
+

The same job for Gradle is on enable or disable the Gradle build cache.

+ +

Run on Maven 3.10.0 with maven-build-cache-extension 1.2.3 and FosterStack Cache 0.2.1, macOS (arm64).

+
+ + + + diff --git a/sitemap.xml b/sitemap.xml index 57f6f62..b07601e 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -179,4 +179,8 @@ https://fosterstack.com/gradle-build-cache-enable-disable/ 2026-10-08 + + https://fosterstack.com/maven-build-cache-extension-enable-disable/ + 2026-10-08 + From da5b6da380a1ca62d8948c2997983fc105f13b20 Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:47:41 -0400 Subject: [PATCH 22/25] Page: Does the build cache survive a restart? (clean stop, kill -9, wrong folder; real run on 0.2.1) --- build-cache-survives-restart/index.html | 140 ++++++++++++++++++++++++ sitemap.xml | 4 + 2 files changed, 144 insertions(+) create mode 100644 build-cache-survives-restart/index.html diff --git a/build-cache-survives-restart/index.html b/build-cache-survives-restart/index.html new file mode 100644 index 0000000..dc02576 --- /dev/null +++ b/build-cache-survives-restart/index.html @@ -0,0 +1,140 @@ + + + + + +Does the Build Cache Survive a Restart? A Real Test + + + + + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Does the build cache survive a restart? A real test

+

You restart the cache server, or it crashes, or the container is replaced. Do your stored build results come back? We tested a clean stop, a forced kill and a wrong data folder with FosterStack Cache 0.2.1 and a real Gradle build.

+ +

The short answer

+
    +
  • After a clean stop and start on the same data folder, every result was still there.
  • +
  • After the server was killed with kill -9 and started again on the same folder, every result was still there.
  • +
  • On a different, empty data folder, the cache was empty and the next build rebuilt everything.
  • +
+

The results live in the data folder, not in the running server. If that folder survives, your cache survives.

+ +

What we ran

+

A four-module Gradle 9.8.0 project (the one from Gradle build cache for a monorepo) against a server set up with FSCACHE_DATA_DIR pointing at a folder. Every build used a fresh copy of the project and an empty Gradle home, so the only shared thing was the server. All four builds ended with BUILD SUCCESSFUL. "From cache" counts the four compileJava tasks that printed FROM-CACHE.

+ + + + + + + + +
StepServer state before the buildEntries in the status pageFrom cache
1. First buildNew, empty data folder A0, then 13 afterwards0 of 4
2. Clean restartStopped normally, started on folder A13 (same as before)4 of 4
3. CrashKilled with kill -9, started on folder A13 (same as before)4 of 4
4. Wrong folderStarted on a different empty folder B00 of 4
+

The status page is the server's /statusz address. It showed the same 13 entries and the same size in bytes right after each restart, before any build ran.

+ +

One limit of this test

+

We killed the server while it was idle. We did not kill it in the middle of an upload, so this page says nothing about a half-written result. What we can say is that a crash with nothing in flight lost nothing.

+ +

If your cache comes back empty

+

An empty cache after a restart means the server is looking at a different folder than before. It is the same as step 4. Check that the data folder or volume is still the one you expect:

+ +

To copy the data somewhere safe, or to move it to another server, see back up and restore.

+ +

Run on FosterStack Cache 0.2.1 (macOS, arm64) with Gradle 9.8.0, Java 27.

+
+ + + + diff --git a/sitemap.xml b/sitemap.xml index b07601e..029b75d 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -183,4 +183,8 @@ https://fosterstack.com/maven-build-cache-extension-enable-disable/ 2026-10-08 + + https://fosterstack.com/build-cache-survives-restart/ + 2026-10-08 + From 61d96af2d2c998c683c4347c6bf857f5d2618a50 Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:08:11 -0400 Subject: [PATCH 23/25] Page: Maven build cache extension in a multi-module project (six real builds on 0.2.1) --- .../index.html | 164 ++++++++++++++++++ sitemap.xml | 4 + 2 files changed, 168 insertions(+) create mode 100644 maven-build-cache-extension-multi-module/index.html diff --git a/maven-build-cache-extension-multi-module/index.html b/maven-build-cache-extension-multi-module/index.html new file mode 100644 index 0000000..3268384 --- /dev/null +++ b/maven-build-cache-extension-multi-module/index.html @@ -0,0 +1,164 @@ + + + + + +Maven Build Cache Extension in a Multi-Module Project + + + + + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Maven build cache extension in a multi-module project: a real run

+

With many modules, the Apache Maven Build Cache Extension keeps one stored result per module. We ran a three-module project against a self-hosted cache (FosterStack Cache 0.2.1, Maven 3.10.0, extension 1.2.3) and recorded which modules were restored and which were built.

+ +

The short answer

+
    +
  • Every module is stored and restored on its own.
  • +
  • Change one module and the modules it depends on are still restored.
  • +
  • Change one module and the modules that depend on it are built again.
  • +
  • You set the cache up once, in the parent project. Modules need no changes.
  • +
+ +

What we ran

+

Three modules: core has no dependencies, util uses core, and app uses util. Each has six small Java classes. Every build used a fresh copy of the project and an empty local build cache, so the only shared thing was the cache server. All six builds ended with BUILD SUCCESS.

+ + + + + + + + + + +
Buildcoreutilapp
1. First build, as CI would do itbuiltbuiltbuilt
2. Another machine, same coderestoredrestoredrestored
3. Edit inside a method in corebuiltbuiltbuilt
4. A teammate makes the same editrestoredrestoredrestored
5. Edit only apprestoredrestoredbuilt
6. Edit only utilrestoredbuiltbuilt
+

"Restored" is the log line Found cached build, restoring demo:core from cache for that module. "Built" means Maven compiled it, then saved the result.

+ +

What this means for your project

+
    +
  • Work flows downstream, not upstream (builds 5 and 6). An edit rebuilt that module and the modules that use it, and nothing before it.
  • +
  • A small edit in a base module rebuilds everything above it (build 3). Even a change inside a method rebuilt all three modules in this run. Put code that changes often in modules near the top of the dependency chain.
  • +
  • The first person pays (builds 3 and 4). The teammate with the same edit built nothing.
  • +
  • This differs from Gradle in our other run. There, a change inside a method in the base module left the modules above it restored; see Gradle build cache for a monorepo. Plan around the tool you use.
  • +
+ +

Set it up once, in the parent

+

Two files in the .mvn folder at the root of the project, the same as for a single module:

+
<!-- .mvn/extensions.xml -->
+<extensions>
+  <extension>
+    <groupId>org.apache.maven.extensions</groupId>
+    <artifactId>maven-build-cache-extension</artifactId>
+    <version>1.2.3</version>
+  </extension>
+</extensions>
+
<!-- .mvn/maven-build-cache-config.xml -->
+<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="true" saveToRemote="true" id="fosterstack-cache">
+      <url>http://127.0.0.1:18180/</url>
+    </remote>
+  </configuration>
+</cache>
+

Replace the address with your server's. We used 127.0.0.1 over plain HTTP because the test ran on one machine; use HTTPS across a network. For logins, see Maven remote build cache setup.

+ +

If modules are not restored

+

See Maven build cache not restoring. To turn the cache off or skip it for one build, see enable or disable the extension.

+ +

Run on Maven 3.10.0 with maven-build-cache-extension 1.2.3 and FosterStack Cache 0.2.1, macOS (arm64).

+
+ + + + diff --git a/sitemap.xml b/sitemap.xml index 029b75d..cf762b0 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -187,4 +187,8 @@ https://fosterstack.com/build-cache-survives-restart/ 2026-10-08 + + https://fosterstack.com/maven-build-cache-extension-multi-module/ + 2026-10-08 + From 5bf0a5e304957b2ae658bef61a8be407c9c02cc8 Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:19:09 -0400 Subject: [PATCH 24/25] Accessibility: readable button text in dark mode, visible secondary button border, keyboard-focusable scrolling code and tables, focus outline --- 404.html | 6 ++-- air-gapped-build-cache-install/index.html | 10 +++--- back-up-and-restore-build-cache/index.html | 14 ++++---- .../index.html | 19 ++++++----- build-cache-eviction-size-limit/index.html | 15 ++++---- build-cache-node-end-of-life/index.html | 6 ++-- build-cache-node-faq/index.html | 6 ++-- .../index.html | 13 +++---- .../index.html | 12 +++---- build-cache-server-sizing/index.html | 15 ++++---- build-cache-survives-restart/index.html | 11 +++--- changelog/index.html | 6 ++-- fips-140-3-build-cache/index.html | 14 ++++---- first-15-minutes-build-cache/index.html | 19 ++++++----- .../index.html | 17 +++++----- gradle-build-cache-corporate-proxy/index.html | 17 +++++----- gradle-build-cache-docker-compose/index.html | 12 +++---- gradle-build-cache-enable-disable/index.html | 13 +++---- gradle-build-cache-hit-rate-low/index.html | 16 ++++----- .../index.html | 17 +++++----- gradle-build-cache-kubernetes/index.html | 6 ++-- gradle-build-cache-monorepo/index.html | 17 +++++----- gradle-build-cache-tutorial/index.html | 22 ++++++------ .../index.html | 21 ++++++------ .../index.html | 12 +++---- gradle-remote-cache-not-working/index.html | 14 ++++---- .../index.html | 13 +++---- how-we-build/index.html | 6 ++-- index.html | 13 +++---- jenkins-ci-build-cache/index.html | 10 +++--- long-term-support-build-cache/index.html | 6 ++-- maven-build-cache-corporate-proxy/index.html | 15 ++++---- .../index.html | 15 ++++---- .../index.html | 15 ++++---- .../index.html | 16 ++++----- maven-remote-build-cache-setup/index.html | 18 +++++----- migrate-build-cache-node/index.html | 24 ++++++------- .../index.html | 19 ++++++----- .../index.html | 17 +++++----- .../index.html | 23 +++++++------ regulated/index.html | 6 ++-- remote-build-cache-security/index.html | 6 ++-- reset-gradle-build-cache/index.html | 14 ++++---- security/index.html | 6 ++-- upgrade-build-cache-server/index.html | 13 +++---- verify-a-release/index.html | 34 +++++++++---------- .../index.html | 12 +++---- verify-replacement-build-cache/index.html | 6 ++-- 48 files changed, 339 insertions(+), 318 deletions(-) diff --git a/404.html b/404.html index 7389ce5..0946a10 100644 --- a/404.html +++ b/404.html @@ -63,15 +63,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } diff --git a/air-gapped-build-cache-install/index.html b/air-gapped-build-cache-install/index.html index 1f0b521..eb2b993 100644 --- a/air-gapped-build-cache-install/index.html +++ b/air-gapped-build-cache-install/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -100,7 +100,7 @@

What to bring across

Verify before you cross the gap

Do this on the connected side, where the signature check can reach its public log:

-
# 1. Prove the checksums file is ours
+  
# 1. Prove the checksums file is ours
 cosign verify-blob \
   --bundle checksums.txt.bundle \
   --certificate-identity-regexp='^https://github.com/fosterstack/cache/' \
@@ -114,7 +114,7 @@ 

Verify before you cross the gap

Install on the isolated side

For the binary: unpack the archive and follow the install guide from the unpack step, including the systemd service. There is no separate offline procedure.

For containers:

-
docker load -i fscache-X.Y.Z.tar
+  
docker load -i fscache-X.Y.Z.tar
 docker run -d -p 127.0.0.1:8080:8080 -v fscache-data:/home/nonroot ghcr.io/fosterstack/cache:X.Y.Z

Real deployments terminate HTTPS at a proxy in front of the server, because Gradle sends the password with every request. The offline install guide has the full detail, including what to do for later upgrades.

diff --git a/back-up-and-restore-build-cache/index.html b/back-up-and-restore-build-cache/index.html index 76eccf6..39c6865 100644 --- a/back-up-and-restore-build-cache/index.html +++ b/back-up-and-restore-build-cache/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -97,24 +97,24 @@

Where the data lives

1. Stop the server, then back up the volume

I stopped the container first. I did not test copying the files while the server is running, so I cannot say that is safe. The container image has no shell, so a second small container does the copying:

-
docker stop fscache
+  
docker stop fscache
 docker run --rm -v fscache-data:/data:ro -v "$PWD":/backup ubuntu:24.04 \
   tar czf /backup/fscache-data.tgz -C /data .

In my test, with three small entries, the archive held a data/blobs/ folder with one folder per entry prefix, and was 578 bytes.

2. Restore it into a new volume

To check it, I deleted the container and the volume, then restored into a new, empty one:

-
docker volume create fscache-data-restored
+  
docker volume create fscache-data-restored
 docker run --rm -v fscache-data-restored:/data -v "$PWD":/backup:ro ubuntu:24.04 \
   sh -c 'tar xzf /backup/fscache-data.tgz -C /data'
 ls -ln /data   # inside the helper: the files are owned by 65532

Listing the restored volume showed the data folder still owned by user and group 65532, which is what the server runs as. If you restore as a different user, the server will not be able to write.

3. Start the server on the restored volume

-
docker run -d --name fscache -p 127.0.0.1:8080:8080 \
+  
docker run -d --name fscache -p 127.0.0.1:8080:8080 \
   -v fscache-data-restored:/home/nonroot ghcr.io/fosterstack/cache:0.2.1

The status page and the entries were all back:

-
$ curl -s localhost:8080/statusz | grep -E '"store_entries"|"store_bytes"'
+  
$ curl -s localhost:8080/statusz | grep -E '"store_entries"|"store_bytes"'
   "store_bytes": 32,
   "store_entries": 3,
 $ curl -s localhost:8080/alpha
diff --git a/build-cache-docker-compose-production/index.html b/build-cache-docker-compose-production/index.html
index e711a06..f6202d6 100644
--- a/build-cache-docker-compose-production/index.html
+++ b/build-cache-docker-compose-production/index.html
@@ -69,15 +69,16 @@
         overflow-x: auto; font-size: .9rem; line-height: 1.5; }
   .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
   .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
-         text-decoration: none; background: var(--accent); color: #fff; }
-  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
+         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
+  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
   .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
   footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
   footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
   a { color: var(--accent); }
-  table { display: block; max-width: 100%; overflow-x: auto; }
   code { overflow-wrap: anywhere; }
   pre code { overflow-wrap: normal; }
+  .table-scroll { max-width: 100%; overflow-x: auto; }
+  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
 
 
 
@@ -96,7 +97,7 @@ 

What was tested, and what was not

compose.yaml

-
services:
+  
services:
   fscache:
     image: ghcr.io/fosterstack/cache:0.2.1
     restart: unless-stopped
@@ -124,7 +125,7 @@ 

compose.yaml

The cache has no ports entry on purpose. Only the proxy is reachable from outside. Generate a real password, for example with openssl rand -base64 24, and never keep change-me.

nginx.conf

-
server {
+  
server {
     listen 443 ssl;
     server_name localhost;
     ssl_certificate     /etc/nginx/certs/cert.pem;
@@ -141,7 +142,7 @@ 

nginx.conf

The client_max_body_size line matters. See the tests below.

What I checked, and what I saw

- +
@@ -151,14 +152,14 @@

What I checked, and what I saw

-
CheckResult
https://localhost:8443/healthz with the certificate trustedok
The same without trusting the certificatecurl refuses (exit 60)
Upload with the password, through the proxy201, and the value reads back
Uploads of about 1 MB and 2 MB with nginx's default settings413 from nginx, not from the cache
The same uploads, and 20 MB, after client_max_body_size 1g;201 each
+

The size limit is the one to watch. nginx rejects bodies over 1 MB by default, and Gradle's cache entries are often bigger, so uploads of larger entries would be refused. The cache itself accepts up to 1 GiB per entry by default, which is why the example uses 1g. See the status codes page.

Gradle over HTTPS

With a certificate Gradle does not trust, the build still succeeds, but without the cache, and prints:

-
Could not load entry d4f2de693b561fd2c5d60b17a9232d6f from remote build cache: (certificate_unknown) PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
+
Could not load entry d4f2de693b561fd2c5d60b17a9232d6f from remote build cache: (certificate_unknown) PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

For my self-signed test certificate I made Java trust it:

-
keytool -importcert -alias fsprod -file certs/cert.pem -keystore trust.jks -storepass changeit -noprompt
+  
keytool -importcert -alias fsprod -file certs/cert.pem -keystore trust.jks -storepass changeit -noprompt
 
 # gradle.properties
 systemProp.javax.net.ssl.trustStore=/path/to/trust.jks
diff --git a/build-cache-eviction-size-limit/index.html b/build-cache-eviction-size-limit/index.html
index ac72c6b..d8197a6 100644
--- a/build-cache-eviction-size-limit/index.html
+++ b/build-cache-eviction-size-limit/index.html
@@ -69,15 +69,16 @@
         overflow-x: auto; font-size: .9rem; line-height: 1.5; }
   .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
   .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
-         text-decoration: none; background: var(--accent); color: #fff; }
-  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
+         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
+  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
   .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
   footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
   footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
   a { color: var(--accent); }
-  table { display: block; max-width: 100%; overflow-x: auto; }
   code { overflow-wrap: anywhere; }
   pre code { overflow-wrap: normal; }
+  .table-scroll { max-width: 100%; overflow-x: auto; }
+  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
 
 
 
@@ -91,17 +92,17 @@ 

Build cache eviction: how the size limit works

Setting the cap

Set FSCACHE_MAX_BYTES, in bytes. The default, 0, means no limit. Real servers use gigabytes; see server sizing for values you can copy. For this test I used 3500 bytes, so that a few small entries fill it:

-
FSCACHE_MAX_BYTES=3500 ./fscache
+
FSCACHE_MAX_BYTES=3500 ./fscache

What happened

I stored three 1,000-byte entries, read one of them, then stored two more. After each step I looked at /statusz, the status page, and tried to read every key back.

- +
-
StepStored afterwardsEvicted so farWhich keys could be read
Store k1, k2, k33,000 bytes, 3 entries0all three
Read k1unchanged0k1 is now the most recently used
Store k4 (would go over 3,500)3,000 bytes, 3 entries1k1, k3, k4. k2 is gone.
Store k53,000 bytes, 3 entries2k3, k4, k5. k1 is gone.
+

The stored size never went above the cap. Each time something new did not fit, the entry that had gone longest without being used was removed.

Reading counts as using. In the last step k1 went too, even though I had read it earlier. That is because my own checks after storing k4 read k1, k3 and k4 in that order, which made k1 the oldest again. A cache hit keeps an entry alive.

@@ -112,7 +113,7 @@

Watching it on a real cache

The same numbers are in /statusz as store_bytes, max_bytes, store_entries and evicted_entries, and in the Prometheus metrics (fscache_evicted_entries_total and fscache_store_bytes). A rising eviction count together with a hit rate that never climbs means the cap is too small for your build. See monitoring.

Try it yourself

-
FSCACHE_ADDR=127.0.0.1:8080 FSCACHE_DATA_DIR=./data FSCACHE_MAX_BYTES=3500 ./fscache &
+  
FSCACHE_ADDR=127.0.0.1:8080 FSCACHE_DATA_DIR=./data FSCACHE_MAX_BYTES=3500 ./fscache &
 until curl -s localhost:8080/healthz >/dev/null; do sleep 0.2; done   # wait until it is up
 
 put() { head -c 1000 /dev/zero | curl -s -o /dev/null -w "PUT $1 %{http_code}\n" -X PUT --data-binary @- localhost:8080/$1; }
diff --git a/build-cache-node-end-of-life/index.html b/build-cache-node-end-of-life/index.html
index be700bd..4be4c36 100644
--- a/build-cache-node-end-of-life/index.html
+++ b/build-cache-node-end-of-life/index.html
@@ -69,15 +69,15 @@
         overflow-x: auto; font-size: .9rem; line-height: 1.5; }
   .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
   .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
-         text-decoration: none; background: var(--accent); color: #fff; }
-  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
+         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
+  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
   .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
   footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
   footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
   a { color: var(--accent); }
-  table { display: block; max-width: 100%; overflow-x: auto; }
   code { overflow-wrap: anywhere; }
   pre code { overflow-wrap: normal; }
+  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
 
 
 
diff --git a/build-cache-node-faq/index.html b/build-cache-node-faq/index.html
index 35bff9e..408bf3f 100644
--- a/build-cache-node-faq/index.html
+++ b/build-cache-node-faq/index.html
@@ -70,15 +70,15 @@
         overflow-x: auto; font-size: .9rem; line-height: 1.5; }
   .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
   .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
-         text-decoration: none; background: var(--accent); color: #fff; }
-  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
+         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
+  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
   .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
   footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
   footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
   a { color: var(--accent); }
-  table { display: block; max-width: 100%; overflow-x: auto; }
   code { overflow-wrap: anywhere; }
   pre code { overflow-wrap: normal; }
+  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
 
 
 
diff --git a/build-cache-node-migration-checklist/index.html b/build-cache-node-migration-checklist/index.html
index b0660ab..75fa9c1 100644
--- a/build-cache-node-migration-checklist/index.html
+++ b/build-cache-node-migration-checklist/index.html
@@ -69,15 +69,16 @@
         overflow-x: auto; font-size: .9rem; line-height: 1.5; }
   .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
   .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
-         text-decoration: none; background: var(--accent); color: #fff; }
-  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
+         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
+  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
   .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
   footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
   footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
   a { color: var(--accent); }
-  table { display: block; max-width: 100%; overflow-x: auto; }
   code { overflow-wrap: anywhere; }
   pre code { overflow-wrap: normal; }
+  .table-scroll { max-width: 100%; overflow-x: auto; }
+  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
 
 
 
@@ -100,7 +101,7 @@ 

2. Start the new server

Follow your first 15 minutes. Turn on a password before other machines can reach it.

3. Change one line

-
buildCache {
+  
buildCache {
     remote<HttpBuildCache> {
 -       url = uri("http://old-node.example.com:5071/cache/")
 +       url = uri("https://cache.example.com/")
@@ -123,13 +124,13 @@ 

7. Retire the old node, and check the new one

What happened in the run

Each row is two builds of the same project with gradle clean in between and Gradle's own local cache turned off, so a hit can only come from the server:

- +
-
Pointed atFirst buildSecond build
The old node, URL ending in /cache/:compileJava ran:compileJava FROM-CACHE
FosterStack Cache, only the URL changed (no /cache/):compileJava ran: the new server was empty:compileJava FROM-CACHE
FosterStack Cache, URL kept with /cache/:compileJava ran: separate entries:compileJava FROM-CACHE
Back to the old node (rollback):compileJava FROM-CACHE: its entries were still therenot run
+

The new server's status page agreed: two entries stored, two hits, two misses. One small difference you may notice: a successful upload returns 200 from the old node and 201 from FosterStack Cache. Gradle accepted both.

Related: questions and answers and what to check in a replacement build cache.

diff --git a/build-cache-permission-denied-data-directory/index.html b/build-cache-permission-denied-data-directory/index.html index 9723d3f..d67c443 100644 --- a/build-cache-permission-denied-data-directory/index.html +++ b/build-cache-permission-denied-data-directory/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -91,17 +91,17 @@

Build cache server exits with "permission denied" on its data

What you see

The container exits with status 1 within a few seconds, and its log says:

-
{"level":"ERROR","msg":"fscache: fatal","error":"write shutdown marker: open data/.unclean-shutdown: permission denied"}
+
{"level":"ERROR","msg":"fscache: fatal","error":"write shutdown marker: open data/.unclean-shutdown: permission denied"}

Why it happens

The server does not run as root. It runs as user 65532, and it keeps its files in a data folder inside /home/nonroot. If that folder already exists and belongs to someone else, the server cannot write to it and stops.

I reproduced it with a volume that already held data owned by root, which is what you get after restoring a backup as root:

-
/d       0:0   drwxr-xr-x      (owner:group, mode)
+  
/d       0:0   drwxr-xr-x      (owner:group, mode)
 /d/data  0:0   drwxr-xr-x

The fix

Give the whole tree to user 65532. The image has no shell, so use a throwaway container to do it:

-
docker run --rm -v fscache-data:/d ubuntu:24.04 chown -R 65532:65532 /d
+
docker run --rm -v fscache-data:/d ubuntu:24.04 chown -R 65532:65532 /d

After that the same image started normally: /healthz answered ok and an upload returned 201.

What does not cause it

diff --git a/build-cache-server-sizing/index.html b/build-cache-server-sizing/index.html index b710fdf..c1ac9ff 100644 --- a/build-cache-server-sizing/index.html +++ b/build-cache-server-sizing/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -90,24 +91,24 @@

How big a server does a build cache need? CPU, RAM and disk

You are sizing a disk, not a compute tier. The server is one small program that idles in tens of megabytes, so the question is how much cache you want to keep.

Starting sizes

- +
-
vCPURAMDisk
Absolute minimum11 GBcache cap + about 5 GB for the system
Recommended start, about 5 to 20 developers plus CI1 to 22 GB25 to 50 GB SSD
Larger CI farms2 to 44 to 8 GBcache cap + about 20%
+

These are starting points from our deployment guide. Your build graph decides the rest. It is hard to be limited by CPU, because the busy path is reading and writing stored files, not computing.

Set a size cap

Choose how much cache you want, set it with FSCACHE_MAX_BYTES, and make the disk about 20% bigger. When the cache reaches the cap it removes the least recently used entries and never grows past it. The extra room covers file system overhead and uploads in progress.

The value is in bytes. It is easy to be wrong by a factor of 1024, so copy one:

- +
-
CapFSCACHE_MAX_BYTES
10 GiB10737418240
20 GiB21474836480
50 GiB53687091200
100 GiB107374182400
+

Two mistakes that look like "the cache doesn't help"

    diff --git a/build-cache-survives-restart/index.html b/build-cache-survives-restart/index.html index dc02576..1953127 100644 --- a/build-cache-survives-restart/index.html +++ b/build-cache-survives-restart/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -99,7 +100,7 @@

    The short answer

    What we ran

    A four-module Gradle 9.8.0 project (the one from Gradle build cache for a monorepo) against a server set up with FSCACHE_DATA_DIR pointing at a folder. Every build used a fresh copy of the project and an empty Gradle home, so the only shared thing was the server. All four builds ended with BUILD SUCCESSFUL. "From cache" counts the four compileJava tasks that printed FROM-CACHE.

    - +
    @@ -107,7 +108,7 @@

    What we ran

    -
    StepServer state before the buildEntries in the status pageFrom cache
    1. First buildNew, empty data folder A0, then 13 afterwards0 of 4
    3. CrashKilled with kill -9, started on folder A13 (same as before)4 of 4
    4. Wrong folderStarted on a different empty folder B00 of 4
    +

    The status page is the server's /statusz address. It showed the same 13 entries and the same size in bytes right after each restart, before any build ran.

    One limit of this test

    diff --git a/changelog/index.html b/changelog/index.html index 0b5e375..2b55c5b 100644 --- a/changelog/index.html +++ b/changelog/index.html @@ -69,16 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } blockquote { border-left: 3px solid var(--line); padding: .2rem 0 .2rem 1rem; margin: .8rem 0; color: var(--muted); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } diff --git a/fips-140-3-build-cache/index.html b/fips-140-3-build-cache/index.html index b54bc38..0572447 100644 --- a/fips-140-3-build-cache/index.html +++ b/fips-140-3-build-cache/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -93,21 +93,21 @@

    What the FIPS build is

    It is the same source as the standard build, compiled so that it uses Go's validated FIPS 140-3 module (CMVP certificate #5247). It is available as a container image tag ending in -fips, and as Linux binaries for amd64 and arm64. There is no macOS FIPS build.

    Run it

    -
    docker run -d -p 127.0.0.1:8080:8080 -v fscache-data:/home/nonroot \
    +  
    docker run -d -p 127.0.0.1:8080:8080 -v fscache-data:/home/nonroot \
       ghcr.io/fosterstack/cache:0.2.1-fips

    Pin an exact version, as here, rather than a floating tag. To check you pulled the real image, use the steps in Verify a release yourself. The FIPS image's digest in the release manifest for 0.2.1 is sha256:e125fe3f6ed2c18c478453300ba2b787a42999edf66a39403877efb39865ab1b, and that is what the pull returned.

    Confirm it is in FIPS mode

    The build says so when it starts, so the property can be seen and not just trusted:

    -
    $ docker logs <container>
    +  
    $ docker logs <container>
     {"time":"2026-10-09T01:00:54.861529344Z","level":"INFO","msg":"fscache: starting","version":"v0.2.1","addr":":8080","data_dir":"./data","max_bytes":0,"auth":"disabled","fips140":"active (Go validated module v1.0.0, CMVP cert #5247)"}

    The status page reports the same:

    -
    $ curl -s localhost:8080/statusz | grep -E 'version|fips140'
    +  
    $ curl -s localhost:8080/statusz | grep -E 'version|fips140'
       "version": "v0.2.1",
       "fips140": true,
       "fips140_note": "active (Go validated module v1.0.0, CMVP cert #5247)",

    The standard image, for comparison, reports it as off:

    -
    $ curl -s localhost:8080/statusz | grep -E 'version|fips140'
    +  
    $ curl -s localhost:8080/statusz | grep -E 'version|fips140'
       "version": "v0.2.1",
       "fips140": false,
       "fips140_note": "off",
    diff --git a/first-15-minutes-build-cache/index.html b/first-15-minutes-build-cache/index.html index ea7250f..2dc0709 100644 --- a/first-15-minutes-build-cache/index.html +++ b/first-15-minutes-build-cache/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -90,13 +91,13 @@

    Your first 15 minutes with a self-hosted build cache

    A checklist from nothing to your first FROM-CACHE. I timed the commands on a real 0.2.1 release: ten seconds in all, on a laptop that already had Gradle and Java and a fast network. The 15 minutes is for you: reading, choosing values, and pointing your own project at the server.

    What I timed

    - +
    -
    StepTook
    Download the release and verify its signature and checksum1 second
    Unpack it and start the server, with a password and a size cap1 second
    Write the project filesunder 1 second
    Build, clean, build again, and see FROM-CACHE8 seconds
    +

    The checklist

    1. Decide where it will run

    @@ -106,7 +107,7 @@

    1. Decide where it will run

2. Download it and check it is ours

-
VER=0.2.1
+  
VER=0.2.1
 PLATFORM=darwin_arm64    # or linux_amd64, linux_arm64, darwin_amd64
 gh release download v${VER} --repo fosterstack/cache \
   -p checksums.txt -p checksums.txt.bundle -p "fscache_${VER}_${PLATFORM}.tar.gz"
@@ -117,7 +118,7 @@ 

2. Download it and check it is ours

You should see Verified OK and then ...tar.gz: OK. More checks are in Verify a release yourself. Prefer a container? See Gradle build cache with Docker Compose.

3. Start it with a password and a size cap

-
tar xzf fscache_${VER}_${PLATFORM}.tar.gz
+  
tar xzf fscache_${VER}_${PLATFORM}.tar.gz
 FSCACHE_USERNAME=gradle FSCACHE_PASSWORD=<a long secret> \
 FSCACHE_MAX_BYTES=1073741824 ./fscache

That cap is 1 GiB, in bytes. Real caches are bigger; copy values from the sizing page.

@@ -130,7 +131,7 @@

4. Check it is up

5. Point Gradle at it

In settings.gradle.kts, and turn caching on with org.gradle.caching=true in gradle.properties:

-
// FosterStack Cache — remote Gradle build cache.
+  
// FosterStack Cache — remote Gradle build cache.
 // https://github.com/fosterstack/cache
 buildCache {
     remote<HttpBuildCache> {
@@ -145,7 +146,7 @@ 

5. Point Gradle at it

Use 127.0.0.1 for a first try on the same machine. On Gradle 9.8.0 the name localhost was refused without an opt-in. Maven instead? See the Maven setup.

6. Build twice and look for FROM-CACHE

-
export FSCACHE_PASSWORD=<a long secret>
+  
export FSCACHE_PASSWORD=<a long secret>
 gradle build --build-cache
 gradle clean
 gradle build --build-cache
diff --git a/gradle-build-cache-ci-writes-developers-read/index.html b/gradle-build-cache-ci-writes-developers-read/index.html
index 0ad874b..8b405fe 100644
--- a/gradle-build-cache-ci-writes-developers-read/index.html
+++ b/gradle-build-cache-ci-writes-developers-read/index.html
@@ -69,15 +69,16 @@
         overflow-x: auto; font-size: .9rem; line-height: 1.5; }
   .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
   .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
-         text-decoration: none; background: var(--accent); color: #fff; }
-  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
+         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
+  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
   .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
   footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
   footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
   a { color: var(--accent); }
-  table { display: block; max-width: 100%; overflow-x: auto; }
   code { overflow-wrap: anywhere; }
   pre code { overflow-wrap: normal; }
+  .table-scroll { max-width: 100%; overflow-x: auto; }
+  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
 
 
 
@@ -91,14 +92,14 @@ 

Gradle build cache: let CI write, developers only read

1. Give the server two logins

The server takes one read-write login and, optionally, a second read-only login. A client using the read-only one can read cache entries, and any write is refused with a 403.

-
FSCACHE_USERNAME=ci   FSCACHE_PASSWORD=<a long secret> \
+  
FSCACHE_USERNAME=ci   FSCACHE_PASSWORD=<a long secret> \
 FSCACHE_RO_USERNAME=dev FSCACHE_RO_PASSWORD=<another secret> \
 ./fscache

The two usernames must differ, and the read-only pair only works alongside the read-write pair. Both are in the configuration table.

2. One Gradle setting for everyone

Developers and CI use the same settings.gradle.kts. Only the environment differs:

-
rootProject.name = "demo"
+  
rootProject.name = "demo"
 
 // FosterStack Cache — remote Gradle build cache.
 // https://github.com/fosterstack/cache
@@ -117,17 +118,17 @@ 

2. One Gradle setting for everyone

3. What happened when I ran it

The server counts what it stores. Each line below is a build of the same small project. (In my test I turned Gradle's local cache off, with local { isEnabled = false } in the same block, so that a hit could only have come from the server. You do not need that line.)

- +
-
Who buildsResultServer after
A developer first, read-only login, CI unset:compileJava runs. Nothing to read yet, and nothing uploaded.0 entries
CI, read-write login, CI=true:compileJava runs, and the result is uploaded.1 entry
The developer again, read-only login> Task :compileJava FROM-CACHE, "1 from cache"1 entry, 1 hit
+

The laptop never wrote to the cache, and still got the benefit of CI's work.

If a laptop is set up to push anyway

I then made a code change, so there was something new to upload, and built with CI=true but only the read-only login. The build still succeeded. With -i Gradle logged:

-
Could not store entry d4f2de693b561fd2c5d60b17a9232d6f in remote build cache: Storing entry at 'http://127.0.0.1:18098/d4f2de693b561fd2c5d60b17a9232d6f' response status 403: Forbidden
+  
Could not store entry d4f2de693b561fd2c5d60b17a9232d6f in remote build cache: Storing entry at 'http://127.0.0.1:18098/d4f2de693b561fd2c5d60b17a9232d6f' response status 403: Forbidden
 The remote build cache was disabled during the build due to errors.

The server stored nothing: it stayed at 1 entry. The same change built by CI with the read-write login was stored (2 entries). So a misconfigured laptop cannot add to the cache, and its build is not broken by that. Note that Gradle switches the remote cache off for the rest of that build once a store fails, so a laptop in this state also stops reading from it for that build.

diff --git a/gradle-build-cache-corporate-proxy/index.html b/gradle-build-cache-corporate-proxy/index.html index af5a242..7232ebd 100644 --- a/gradle-build-cache-corporate-proxy/index.html +++ b/gradle-build-cache-corporate-proxy/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -97,7 +98,7 @@

What was tested, and what was not

The settings

Gradle takes proxy settings from Java system properties. Put them in ~/.gradle/gradle.properties or the project's gradle.properties, with the systemProp. prefix:

-
systemProp.http.proxyHost=proxy.example.com
+  
systemProp.http.proxyHost=proxy.example.com
 systemProp.http.proxyPort=3128
 systemProp.https.proxyHost=proxy.example.com
 systemProp.https.proxyPort=3128
@@ -106,7 +107,7 @@ 

The settings

What I saw

For the plain-HTTP rows I set isAllowInsecureProtocol = true, which Gradle requires for non-loopback http://. That is for the test rig only; use HTTPS for real.

- +
@@ -114,14 +115,14 @@

What I saw

-
SetupResultWhat the proxy logged
No proxy settings, cache at <cache-ip>second build FROM-CACHEnothing (not used)
Proxy settings, same plain-HTTP URLFROM-CACHEGET http://<cache-ip>:18130/<key>, one per build
Proxy settings, with the cache's address in nonProxyHostsFROM-CACHEnothing
Proxy settings, HTTPS cache, tunnel allowedFROM-CACHECONNECT <cache-ip>:18443, one per build
Proxy settings, proxy refuses the cache hostthe build succeeds but without the cache (log below)the request, answered 403
+

Three things that catch people

1. Testing against localhost skips the proxy. With the cache at 127.0.0.1, Gradle never used the proxy even though it was configured. A setup that works on your laptop against a local cache proves nothing about the proxy path. Test against the real address.

2. A refusing proxy looks like a cache error. When the proxy answered 403 to the tunnel, Gradle printed:

-
Could not load entry d4f2de693b561fd2c5d60b17a9232d6f from remote build cache: Loading entry from 'https://<cache-ip>:18443/d4f2de693b561fd2c5d60b17a9232d6f' response status 403: Forbidden
+
Could not load entry d4f2de693b561fd2c5d60b17a9232d6f from remote build cache: Loading entry from 'https://<cache-ip>:18443/d4f2de693b561fd2c5d60b17a9232d6f' response status 403: Forbidden

That is the same text you would get from the cache refusing a write with the read-only login (see the status codes). To tell them apart, take Gradle out of it and try the same route with curl:

-
$ curl -sS -x http://proxy.example.com:3128 https://cache.example.com/healthz
+  
$ curl -sS -x http://proxy.example.com:3128 https://cache.example.com/healthz
 curl: (56) CONNECT tunnel failed, response 403      # the proxy said no
 $ curl -sS --noproxy '*' https://cache.example.com/healthz
 ok                                                   # the cache is fine
diff --git a/gradle-build-cache-docker-compose/index.html b/gradle-build-cache-docker-compose/index.html index 36053a6..c4e4344 100644 --- a/gradle-build-cache-docker-compose/index.html +++ b/gradle-build-cache-docker-compose/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -90,12 +90,12 @@

Gradle build cache with Docker Compose

Run a shared Gradle build cache for your team in one Compose file, with a password and a size limit. This is the shape we suggest once you go past a first trial.

Try it first, on one machine

-
docker run -d -p 127.0.0.1:8080:8080 -v fscache-data:/home/nonroot ghcr.io/fosterstack/cache:latest
+
docker run -d -p 127.0.0.1:8080:8080 -v fscache-data:/home/nonroot ghcr.io/fosterstack/cache:latest

The 127.0.0.1: keeps it reachable from this machine only. A bare -p 8080:8080 opens the port to every network interface, and on a cloud host that is a cache anyone can write to the moment the command returns. Pin a version tag instead of latest for anything real.

The Compose file

This one is open to your CI runners, so it has a password on purpose. A cache that untrusted people can write to can feed files into your builds.

-
services:
+  
services:
   fscache:
     image: ghcr.io/fosterstack/cache:latest   # pin a version tag
     restart: unless-stopped
@@ -117,7 +117,7 @@ 

The Compose file

Check it works, with no Gradle project

-
curl -s localhost:8080/healthz                                   # ok
+  
curl -s localhost:8080/healthz                                   # ok
 curl -s -X PUT --data-binary 'hello' localhost:8080/testkey123   # 201
 curl -s localhost:8080/testkey123                                # hello

Add -u gradle:your-password to those calls once you have set a password.

diff --git a/gradle-build-cache-enable-disable/index.html b/gradle-build-cache-enable-disable/index.html index edee0a7..537e939 100644 --- a/gradle-build-cache-enable-disable/index.html +++ b/gradle-build-cache-enable-disable/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -99,7 +100,7 @@

The short answer

What we ran

A four-module project with the cache address set in settings.gradle.kts (shown below). Each run used a fresh copy of the project and an empty Gradle home, so the only shared thing was the cache server. We ran only :core:compileJava and looked at what Gradle printed next to it. Every build ended with BUILD SUCCESSFUL.

- +
@@ -110,7 +111,7 @@

What we ran

-
How it was switchedCommandResult
Flag (first build)gradle :core:compileJava --build-cacheRan, and saved the result to the server
System propertygradle :core:compileJava -Dorg.gradle.caching=trueFROM-CACHE
Flag, and rerun everything--build-cache --rerun-tasksRan, even though a result was stored
+

Which one to use

    @@ -122,7 +123,7 @@

    Which one to use

    The address still has to be set

    This is the settings.gradle.kts we ran. Replace the address with your server's. We used 127.0.0.1 over plain HTTP because the test ran on one machine; use HTTPS across a network.

    -
    buildCache {
    +
    buildCache {
         local { isEnabled = false }
         remote<HttpBuildCache> {
             url = uri("http://127.0.0.1:18141/")
    diff --git a/gradle-build-cache-hit-rate-low/index.html b/gradle-build-cache-hit-rate-low/index.html
    index 254d648..00689e7 100644
    --- a/gradle-build-cache-hit-rate-low/index.html
    +++ b/gradle-build-cache-hit-rate-low/index.html
    @@ -69,15 +69,15 @@
             overflow-x: auto; font-size: .9rem; line-height: 1.5; }
       .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
       .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
    -         text-decoration: none; background: var(--accent); color: #fff; }
    -  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
    +         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
    +  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
       .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
       footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
       footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
       a { color: var(--accent); }
    -  table { display: block; max-width: 100%; overflow-x: auto; }
       code { overflow-wrap: anywhere; }
       pre code { overflow-wrap: normal; }
    +  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
     
     
     
    @@ -91,12 +91,12 @@ 

    Gradle build cache hit rate low: the common causes

    How to find the cause

    Run the build with --info. For every cacheable task Gradle prints its key:

    -
    Build cache key for task ':greetStable' is 932183de90ed643c9e448f23bd169d87
    +
    Build cache key for task ':greetStable' is 932183de90ed643c9e448f23bd169d87

    If the same task has a different key on two machines or two runs, something in its inputs differs. Compare the keys first, then look for what changed. If the key is the same and the task still ran, look at the server (the last section).

    The test project

    Java sources plus three small cacheable tasks in build.gradle.kts. They differ only in the value they take as input:

    -
    @CacheableTask
    +  
    @CacheableTask
     abstract class Greet : DefaultTask() {
         @get:Input abstract val label: Property<String>
         @get:OutputFile abstract val out: RegularFileProperty
    @@ -119,12 +119,12 @@ 

    The test project

    Cause 1: a path that is only true on one machine

    I copied the project to a different directory and built the same tasks from a clean checkout:

    -
    > Task :compileJava FROM-CACHE
    +  
    > Task :compileJava FROM-CACHE
     > Task :greetStable FROM-CACHE
     > Task :greetAbsPath
     > Task :greetTime

    Moving the checkout did not matter for compileJava or for the task with a stable input. The task whose input holds the project's absolute path ran again. The keys show why:

    -
    $ gradle greetAbsPath --info        (in the first directory)
    +  
    $ gradle greetAbsPath --info        (in the first directory)
     Build cache key for task ':greetAbsPath' is 24c324403b9dacb75e78e128fbf76848
     $ gradle greetAbsPath --info        (in the copy)
     Build cache key for task ':greetAbsPath' is b7b4f69d180d1dc577a36f2869d0806e
    @@ -135,7 +135,7 @@

    Cause 2: a value that changes on every build

    Cause 3: a different Java

    The cache had been filled by Gradle running on JDK 27. I ran the same project, in the same directory, with Gradle on JDK 21:

    -
    > Task :compileJava
    +  
    > Task :compileJava
     > Task :greetStable

    Both ran again. A second run on JDK 21 hit, and going back to JDK 27 hit its own entries. So two Java versions on your CI agents and laptops means two separate sets of entries and a lower hit rate. Fix: use the same Java version everywhere builds share a cache.

    diff --git a/gradle-build-cache-http-status-codes/index.html b/gradle-build-cache-http-status-codes/index.html index 1c627bd..3c513bf 100644 --- a/gradle-build-cache-http-status-codes/index.html +++ b/gradle-build-cache-http-status-codes/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -90,7 +91,7 @@

    Gradle build cache HTTP status codes: 401, 403, 404 and 413

    Gradle talks to the cache with plain web requests, and it does not fail the build when the cache says no. So the status code is often the only clue. This is what each one means for a FosterStack Cache server.

    The short table

    - +
    @@ -98,11 +99,11 @@

    The short table

    -
    StatusWhen you get itWhat to do
    200A stored entry was found and sent.Nothing. This is a cache hit.
    201An upload was stored.Nothing.
    401No password, or the wrong one.Fix the credentials block. See below.
    403A write sent with the read-only login.Use the read-write login for machines that upload, or set the client not to upload.
    413An upload bigger than the server's size limit for one entry.Raise FSCACHE_MAX_BODY_BYTES if the entry is meant to be cached.
    +

    Tested on a real server

    These were all run against a 0.2.1 server started with a read-write login, a read-only login, and the body limit set to 1024 bytes. Your numbers for the limit will differ, since the default is 1 GiB.

    -
    GET /healthz (no credentials)                                  200
    +  
    GET /healthz (no credentials)                                  200
     GET /metrics (no credentials)                                  200
     GET key, no credentials                                        401
     GET key, wrong password                                        401
    @@ -120,9 +121,9 @@ 

    Tested on a real server

    401 does not break the build

    Gradle treats a remote cache failure as non-fatal, so a 401 does not fail the build. The build just runs without the cache. In our test with Gradle 9.8.0 on macOS it printed this at the default log level:

    -
    Could not load entry d4f2de693b561fd2c5d60b17a9232d6f from remote build cache: Loading entry from 'http://127.0.0.1:18099/d4f2de693b561fd2c5d60b17a9232d6f' response status 401: Unauthorized
    +
    Could not load entry d4f2de693b561fd2c5d60b17a9232d6f from remote build cache: Loading entry from 'http://127.0.0.1:18099/d4f2de693b561fd2c5d60b17a9232d6f' response status 401: Unauthorized

    The usual causes are no credentials block, a wrong password, or a typo. Run with -i for more detail. To test the server directly:

    -
    curl -s -o /dev/null -w '%{http_code}\n' -u gradle:wrong-password https://cache.example.com/some-key
    +
    curl -s -o /dev/null -w '%{http_code}\n' -u gradle:wrong-password https://cache.example.com/some-key

    A 401 here means the credentials are wrong. A 404 means they are right and the key simply is not stored. More steps are in Gradle remote build cache not working.

    403 means the login worked but may not write

    diff --git a/gradle-build-cache-kubernetes/index.html b/gradle-build-cache-kubernetes/index.html index 007df36..79a14ea 100644 --- a/gradle-build-cache-kubernetes/index.html +++ b/gradle-build-cache-kubernetes/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } diff --git a/gradle-build-cache-monorepo/index.html b/gradle-build-cache-monorepo/index.html index 26c5b5c..9ade795 100644 --- a/gradle-build-cache-monorepo/index.html +++ b/gradle-build-cache-monorepo/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -101,7 +102,7 @@

    What we ran

    Every run below used a brand-new copy of the project and an empty Gradle home folder, and the local cache was switched off. The only thing the runs shared was the cache server. Each run was gradle build --console=plain --no-daemon, and every build finished with BUILD SUCCESSFUL.

    Results: what each module's compile step did

    - +
    @@ -111,9 +112,9 @@

    Results: what each module's compile step did

    -
    Runcoreutilapiapp
    1. First build, as CI would do itranranranran
    5. Add a public method to coreranranranran
    6. Edit only appfrom cachefrom cachefrom cacheran
    +

    "Ran" means Gradle did the work and uploaded the result. "From cache" means Gradle downloaded it instead, and printed FROM-CACHE next to the task. This is what run 3 printed:

    -
    > Task :core:compileJava
    +
    > Task :core:compileJava
     > Task :util:compileJava FROM-CACHE
     > Task :api:compileJava FROM-CACHE
     > Task :app:compileJava FROM-CACHE
    @@ -128,7 +129,7 @@

    What this means for your monorepo

    Set it up

    This is the exact settings.gradle.kts we ran. Replace the address with your server's. We used 127.0.0.1 over plain HTTP because the test ran on one machine; use HTTPS across a network.

    -
    buildCache {
    +
    buildCache {
         local { isEnabled = false }
         remote<HttpBuildCache> {
             url = uri("http://127.0.0.1:18140/")
    @@ -137,7 +138,7 @@ 

    Set it up

    } }

    And this is the whole gradle.properties:

    -
    org.gradle.caching=true
    +
    org.gradle.caching=true
     org.gradle.parallel=true

    In a real team, let only CI upload (isPush) and have developers read. That setup, with a read-only login, is on the CI writes, developers read page.

    diff --git a/gradle-build-cache-tutorial/index.html b/gradle-build-cache-tutorial/index.html index 6b98b56..7f4eb62 100644 --- a/gradle-build-cache-tutorial/index.html +++ b/gradle-build-cache-tutorial/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -91,14 +91,14 @@

    Gradle build cache tutorial: see FROM-CACHE with a self-hosted cache

    1. Start the server

    Download and verify the release as shown in Verify a release yourself, unpack it, and run it with a password and a size cap:

    -
    FSCACHE_ADDR=127.0.0.1:8080 FSCACHE_DATA_DIR=./data \
    +  
    FSCACHE_ADDR=127.0.0.1:8080 FSCACHE_DATA_DIR=./data \
       FSCACHE_USERNAME=gradle FSCACHE_PASSWORD=demo-pass \
       FSCACHE_MAX_BYTES=1073741824 ./fscache

    Prefer a container? Gradle build cache with Docker Compose has the same settings. (My test run used port 18090 instead of 8080; the outputs below do not show the port.)

    2. A small project

    Four files. settings.gradle.kts says where the cache is:

    -
    rootProject.name = "demo"
    +  
    rootProject.name = "demo"
     
     // FosterStack Cache — remote Gradle build cache.
     // https://github.com/fosterstack/cache
    @@ -114,11 +114,11 @@ 

    2. A small project

    } }

    Turning the local cache off is only for this test. It means a hit can only have come from the remote. build.gradle.kts is one line, plugins { java }. gradle.properties switches caching on, which the buildCache block does not do by itself:

    -
    org.gradle.caching=true
    +
    org.gradle.caching=true

    And src/main/java/demo/App.java is any small class.

    3. Build once

    -
    export FSCACHE_PASSWORD=demo-pass
    +  
    export FSCACHE_PASSWORD=demo-pass
     gradle build --build-cache
     
     > Task :compileJava
    @@ -135,13 +135,13 @@ 

    3. Build once

    BUILD SUCCESSFUL in 5s 2 actionable tasks: 2 executed

    Nothing came from the cache, and every cacheable task ran. Gradle stored the compile result on the server. The status page agrees:

    -
    $ curl -s -u gradle:demo-pass localhost:8080/statusz | grep -E '"store_entries"|"cache_hits"|"cache_misses"'
    +  
    $ curl -s -u gradle:demo-pass localhost:8080/statusz | grep -E '"store_entries"|"cache_hits"|"cache_misses"'
       "store_entries": 1,
       "cache_hits": 0,
       "cache_misses": 1,

    4. Clean, and build again

    -
    gradle clean
    +  
    gradle clean
     gradle build --build-cache
     
     > Task :compileJava FROM-CACHE
    @@ -158,14 +158,14 @@ 

    4. Clean, and build again

    BUILD SUCCESSFUL in 5s 2 actionable tasks: 1 executed, 1 from cache

    There it is: :compileJava FROM-CACHE and "1 from cache". The server counted the hit:

    -
    $ curl -s -u gradle:demo-pass localhost:8080/statusz | grep -E '"store_entries"|"cache_hits"|"cache_misses"'
    +  
    $ curl -s -u gradle:demo-pass localhost:8080/statusz | grep -E '"store_entries"|"cache_hits"|"cache_misses"'
       "store_entries": 1,
       "cache_hits": 1,
       "cache_misses": 1,

    The one thing that tripped me up

    My first attempt failed before any build ran, because I used http://localhost:...:

    -
    FAILURE: Build failed with an exception.
    +  
    FAILURE: Build failed with an exception.
     
     * What went wrong:
     Using insecure protocols with remote build cache, without explicit opt-in, is unsupported.
    diff --git a/gradle-configuration-cache-vs-build-cache/index.html b/gradle-configuration-cache-vs-build-cache/index.html index aba0018..65fa9f0 100644 --- a/gradle-configuration-cache-vs-build-cache/index.html +++ b/gradle-configuration-cache-vs-build-cache/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -90,37 +91,37 @@

    Gradle configuration cache vs build cache

    They sound alike and they are not the same thing. One saves Gradle from working out your build again. The other saves your tasks from running again. You can use both, and only the second one uses a cache server. This is a real run of both together, on Gradle 9.8.0 and a 0.2.1 server.

    The short answer

    - +
    -
    Configuration cacheBuild cache
    What it savesThe work of reading your build files and planning the tasksThe work of running a task, by reusing its saved output
    Where it livesA folder in your project, on your machineOn your machine, and optionally on a server shared with a team
    Turned on with--configuration-cache--build-cache (and a server URL for a shared one)
    Thrown away whenA build file it depends on changesA task's inputs change, so it gets a different key
    Shows up as"Reusing configuration cache."FROM-CACHE next to a task
    +

    A remote cache server such as FosterStack Cache is for the build cache only. If Gradle suggests enabling the configuration cache, that is a separate local feature and has nothing to do with the server.

    Both together, in a real run

    The same small project, both flags on, the build cache pointed at a server:

    -
    gradle build --configuration-cache --build-cache
    +
    gradle build --configuration-cache --build-cache

    1. The first build. Nothing is cached anywhere yet:

    -
    Calculating task graph as no cached configuration is available for tasks: build
    +  
    Calculating task graph as no cached configuration is available for tasks: build
     > Task :compileJava
     BUILD SUCCESSFUL in 5s
     Configuration cache entry stored.

    2. The same build again. Both caches work, in different ways. The plan is reused, and the task is simply up to date on disk:

    -
    Reusing configuration cache.
    +  
    Reusing configuration cache.
     > Task :compileJava UP-TO-DATE
     BUILD SUCCESSFUL in 1s
     Configuration cache entry reused.

    3. After gradle clean. The build outputs are gone, so the task would have to run again. The plan is still reused, and the output now comes from the server:

    -
    Reusing configuration cache.
    +  
    Reusing configuration cache.
     > Task :compileJava FROM-CACHE
     BUILD SUCCESSFUL in 1s
     Configuration cache entry reused.

    The server's status page counted one hit for that, and it holds the compiled output only.

    4. After editing build.gradle.kts, then gradle clean. Now the configuration cache is thrown away, because a build file changed, but the build cache still works, because the task's inputs did not change:

    -
    Calculating task graph as configuration cache cannot be reused because file 'build.gradle.kts' has changed.
    +  
    Calculating task graph as configuration cache cannot be reused because file 'build.gradle.kts' has changed.
     > Task :compileJava FROM-CACHE
     BUILD SUCCESSFUL in 2s
     Configuration cache entry stored.
    diff --git a/gradle-remote-build-cache-authentication/index.html b/gradle-remote-build-cache-authentication/index.html index f80906c..a6ab879 100644 --- a/gradle-remote-build-cache-authentication/index.html +++ b/gradle-remote-build-cache-authentication/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -90,12 +90,12 @@

    Gradle remote build cache authentication and 401 errors

    How to put a password on a self-hosted build cache and give Gradle the right one, three ways to supply it, and what you see when it is wrong. Every output below is from a real run: a 0.2.1 server and Gradle 9.8.0 on macOS.

    Turn on a password on the server

    -
    FSCACHE_USERNAME=gradle FSCACHE_PASSWORD=<a long secret> ./fscache
    +
    FSCACHE_USERNAME=gradle FSCACHE_PASSWORD=<a long secret> ./fscache

    This is HTTP Basic Auth. The health and metrics paths stay open without a password, so monitoring still works. The status page does not. For a second, read-only login see let CI write, developers only read.

    Tell Gradle the password

    In settings.gradle.kts:

    -
    // FosterStack Cache — remote Gradle build cache.
    +  
    // FosterStack Cache — remote Gradle build cache.
     // https://github.com/fosterstack/cache
     buildCache {
         remote<HttpBuildCache> {
    @@ -119,7 +119,7 @@ 

    Three ways to supply the password

    What a wrong or missing password looks like

    Gradle treats a broken remote cache as non-fatal, so the build still succeeds, just without the cache. In my runs it printed this at the default log level, with a wrong password and with none:

    -
    Could not load entry d4f2de693b561fd2c5d60b17a9232d6f from remote build cache: Loading entry from 'http://127.0.0.1:18099/d4f2de693b561fd2c5d60b17a9232d6f' response status 401: Unauthorized
    +
    Could not load entry d4f2de693b561fd2c5d60b17a9232d6f from remote build cache: Loading entry from 'http://127.0.0.1:18099/d4f2de693b561fd2c5d60b17a9232d6f' response status 401: Unauthorized

    If you see that line, the password Gradle sent was wrong or missing. To test the server directly, a 401 means wrong credentials and a 404 means the credentials are right and the key is not stored yet. See the status codes.

    Do I need to restart the Gradle daemon?

    diff --git a/gradle-remote-cache-not-working/index.html b/gradle-remote-cache-not-working/index.html index 2a559f8..8709b8b 100644 --- a/gradle-remote-cache-not-working/index.html +++ b/gradle-remote-cache-not-working/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -91,13 +91,13 @@

    Gradle remote build cache not working: no FROM-CACHE

    1. Is the build cache switched on?

    The buildCache block in settings.gradle(.kts) says where the cache is. It does not turn caching on. Without this line the block does nothing:

    -
    # gradle.properties
    +  
    # gradle.properties
     org.gradle.caching=true

    Or pass --build-cache on each run. This is the most common cause.

    2. Is the local cache hiding the problem?

    Gradle's local cache is on by default and gives hits even when the remote is failing. So FROM-CACHE on its own does not prove the remote works. Take the local cache out of the picture and show remote errors:

    -
    rm -rf ~/.gradle/caches/build-cache-1
    +  
    rm -rf ~/.gradle/caches/build-cache-1
     ./gradlew --stop
     ./gradlew build --build-cache -i

    Run it twice. The second run is the one that tells you something.

    @@ -109,7 +109,7 @@

    3. Is the password reaching Gradle?

  • Check that the variable is set in the shell or CI job that runs Gradle. In our test with Gradle 9.8.0 on macOS, a password exported after the background daemon was already running was used by the very next build (same daemon, no restart), and a wrong one gave the 401 straight away. If a password still seems ignored, run ./gradlew --stop to rule the daemon out, or put it in ~/.gradle/gradle.properties (never in the repo).

To test the server's password directly, a 401 means the credentials are wrong and a 404 means they are right and the key just is not there yet:

-
curl -s -o /dev/null -w '%{http_code}\n' -u gradle:wrong-password \
+  
curl -s -o /dev/null -w '%{http_code}\n' -u gradle:wrong-password \
   https://cache.example.com/some-key

4. Is the URL right, and is it HTTPS?

@@ -117,7 +117,7 @@

4. Is the URL right, and is it HTTPS?

One trap in test setups: some sample projects run a check that fails any build containing an http:// URL, including the cache URL. That is the check, not the cache.

5. Is the server seeing requests?

-
curl -s https://cache.example.com/metrics | grep -E 'fscache_cache_(hits|misses)_total'
+
curl -s https://cache.example.com/metrics | grep -E 'fscache_cache_(hits|misses)_total'

Run a build and run this again. If neither counter moves, the requests are not arriving. If misses move and hits do not, the cache is working and has not been filled yet, or something in the task inputs changes on every build.

6. Is this client allowed to upload?

diff --git a/how-our-pipeline-maps-to-nist-and-slsa/index.html b/how-our-pipeline-maps-to-nist-and-slsa/index.html index 721c7ff..69adac7 100644 --- a/how-our-pipeline-maps-to-nist-and-slsa/index.html +++ b/how-our-pipeline-maps-to-nist-and-slsa/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -100,7 +101,7 @@

The two NIST documents

Stage by stage: what runs, and which practice it maps to

This describes the workflow files as they were at the v0.2.1 tag. "Signs" means the stage writes a statement about its work and signs it with keyless signing tied to that stage's own workflow identity, logged in a public transparency log.

- +
@@ -166,7 +167,7 @@

Stage by stage: what runs, and which practice it maps to

-
StageWhat it does in v0.2.1NIST practiceHow much
Implements the practice. Neither document has a section on VEX.
+

Which SLSA Build level does v0.2.1 meet?

We assess v0.2.1 at SLSA Build Level 2, against the SLSA v1.0 requirements. We do not claim Build Level 3.

@@ -196,7 +197,7 @@

What is not met today

Check it yourself

This checks the SLSA provenance of the v0.2.1 image and the workflow that signed it:

-
$ gh attestation verify oci://ghcr.io/fosterstack/cache:0.2.1 \
+  
$ gh attestation verify oci://ghcr.io/fosterstack/cache:0.2.1 \
   --repo fosterstack/cache \
   --signer-workflow fosterstack/cache/.github/workflows/stage-image.yml
    diff --git a/how-we-build/index.html b/how-we-build/index.html index bd9576f..acacb38 100644 --- a/how-we-build/index.html +++ b/how-we-build/index.html @@ -69,8 +69,8 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } @@ -78,9 +78,9 @@ ol.steps { margin: .6rem 0 0 1.3rem; } ol.steps li { padding: .3rem 0; } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } diff --git a/index.html b/index.html index e08f8b0..391dbe1 100644 --- a/index.html +++ b/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -106,7 +107,7 @@

    Fast builds from cache are worth keeping.

    Try it now

    There is no signup, no waitlist, and no license key for the free tier. Pull the image and point your build at it:

    -
    docker run -d -p 8080:8080 ghcr.io/fosterstack/cache:latest
    +  
    docker run -d -p 8080:8080 ghcr.io/fosterstack/cache:latest
     curl localhost:8080/healthz   # -> ok

    Quickstart @@ -183,7 +184,7 @@

    What we do not collect

    Pricing

    Per product. Free is the full product.

    - +
    -
    FreeTeamBusinessCompliance
    The full cache server, MIT-licensed, self-hosted. Every security patch, the @@ -197,7 +198,7 @@

    Pricing

    security-questionnaire support up to 8 hours per year (async), and named-version LTS. Who this is for.
    +

    No sales call. No per-seat tax. No enterprise pricing mystery.

    The Compliance tier, plainly: the security evidence underneath — SBOMs, SLSA diff --git a/jenkins-ci-build-cache/index.html b/jenkins-ci-build-cache/index.html index c531f32..9046406 100644 --- a/jenkins-ci-build-cache/index.html +++ b/jenkins-ci-build-cache/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -95,12 +95,12 @@

    How a CI runner uses a remote build cache

    Let CI write, let developers read

    The simplest safe setup is that only CI uploads to the cache and laptops only download. The server can enforce this: give laptops the optional read-only login (FSCACHE_RO_USERNAME and FSCACHE_RO_PASSWORD), and any write they attempt is refused. On the Gradle side you can also stop uploads from non-CI machines in settings.gradle.kts:

    -
    isPush = System.getenv("CI") != null
    +
    isPush = System.getenv("CI") != null

    That line alone is the client choosing not to upload, not a rule on the server. Anyone holding the read-write password can write, so share it only with people and systems you already trust to change your code.

    Runners inside your Kubernetes cluster

    If your runners already run in the cluster that hosts the cache (including Jenkins agents), you need no load balancer and no ingress. Point the build at the cluster-internal name:

    -
    // FosterStack Cache — remote Gradle build cache.
    +  
    // FosterStack Cache — remote Gradle build cache.
     // https://github.com/fosterstack/cache
     buildCache {
         remote<HttpBuildCache> {
    diff --git a/long-term-support-build-cache/index.html b/long-term-support-build-cache/index.html
    index df53773..0afba7e 100644
    --- a/long-term-support-build-cache/index.html
    +++ b/long-term-support-build-cache/index.html
    @@ -69,15 +69,15 @@
             overflow-x: auto; font-size: .9rem; line-height: 1.5; }
       .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
       .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
    -         text-decoration: none; background: var(--accent); color: #fff; }
    -  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
    +         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
    +  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
       .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
       footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
       footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
       a { color: var(--accent); }
    -  table { display: block; max-width: 100%; overflow-x: auto; }
       code { overflow-wrap: anywhere; }
       pre code { overflow-wrap: normal; }
    +  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
     
     
     
    diff --git a/maven-build-cache-corporate-proxy/index.html b/maven-build-cache-corporate-proxy/index.html
    index 193171e..7992cff 100644
    --- a/maven-build-cache-corporate-proxy/index.html
    +++ b/maven-build-cache-corporate-proxy/index.html
    @@ -69,15 +69,16 @@
             overflow-x: auto; font-size: .9rem; line-height: 1.5; }
       .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
       .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
    -         text-decoration: none; background: var(--accent); color: #fff; }
    -  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
    +         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
    +  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
       .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
       footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
       footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
       a { color: var(--accent); }
    -  table { display: block; max-width: 100%; overflow-x: auto; }
       code { overflow-wrap: anywhere; }
       pre code { overflow-wrap: normal; }
    +  .table-scroll { max-width: 100%; overflow-x: auto; }
    +  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
     
     
     
    @@ -97,7 +98,7 @@ 

    What was tested, and what was not

    The settings

    In ~/.m2/settings.xml, next to the cache's password entry:

    -
    <settings>
    +  
    <settings>
       <servers>
         <server>
           <id>fosterstack-cache</id>
    @@ -120,7 +121,7 @@ 

    The settings

    What went through the proxy

    With the proxy configured, all of the extension's cache traffic went through it: the lookups, the download that restores a build, and the uploads. This is what the proxy logged for two builds, the first filling the cache and the second restoring from it (the proxy also saw Maven's own traffic to repository.apache.org, which is not shown):

    -
    build 1 (stores)
    +  
    build 1 (stores)
     GET http://<cache-ip>:18132/v1.1/demo/demo/<id>/buildinfo.xml
     GET http://<cache-ip>:18132/v1.1/demo/demo/<id>/buildinfo.xml
     PUT http://<cache-ip>:18132/v1.1/demo/demo/<id>/demo.jar
    @@ -135,13 +136,13 @@ 

    What went through the proxy

    The second build logged Found cached build, restoring demo:demo from cache.

    Other cases

    - +
    -
    SetupWhat happened
    Proxy configured, cache URL is 127.0.0.1Still went through the proxy (4 requests logged). Unlike Gradle, Maven does not skip the proxy for loopback.
    Proxy configured, the cache's address in <nonProxyHosts>Nothing reached the proxy. The build still restored from the cache.
    Proxy refuses the cache's hostNothing was stored or restored. The build succeeded without the cache, and logged Error downloading cache item with HTTP Status: 403.
    Proxy not running at allThe lookup failed with Connect to 127.0.0.1:18131 ... Connection refused. The build succeeded without the cache, and nothing was uploaded.
    +

    Things that catch people

      diff --git a/maven-build-cache-extension-enable-disable/index.html b/maven-build-cache-extension-enable-disable/index.html index d5ed5a5..d7f5741 100644 --- a/maven-build-cache-extension-enable-disable/index.html +++ b/maven-build-cache-extension-enable-disable/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -99,7 +100,7 @@

      The short answer

      What we ran

      A small Maven project with the extension set up as described in Maven remote build cache setup. Every build used a fresh copy of the project and an empty local build cache, so the only shared thing was the server. All seven builds ended with BUILD SUCCESS.

      - +
      @@ -110,10 +111,10 @@

      What we ran

      -
      BuildAdded to the commandResult
      1nothing (empty server)Compiled, and saved the result to the server
      6-Dmaven.build.cache.remote.save.enabled=false (empty server)Compiled. The server still had 0 entries
      7same as 6, againCompiled again, because nothing was ever stored
      +

      What Maven prints for each switch

      -
      enabled=false:
      +
      enabled=false:
       [INFO] Cache disabled by command line flag, project will be built fully and not cached
       
       skipCache=true:
      @@ -122,7 +123,7 @@ 

      What Maven prints for each switch

      "Error downloading cache item" on a normal miss

      The first build against an empty server printed this line, and it still succeeded:

      -
      [ERROR] Error downloading cache item: http://127.0.0.1:18160//v1.1/demo/demo/ebb9bceba2f7a6bc/buildinfo.xml
      +
      [ERROR] Error downloading cache item: http://127.0.0.1:18160//v1.1/demo/demo/ebb9bceba2f7a6bc/buildinfo.xml
       [INFO] Remote cache is incomplete or missing, trying local build for demo:demo

      In our run this was an ordinary miss: nothing was stored under that key yet, so Maven built the project itself. The next line says so. If you see this line on a cache that you know holds the result, check the address, the login and the proxy: see Maven build cache not restoring.

      diff --git a/maven-build-cache-extension-multi-module/index.html b/maven-build-cache-extension-multi-module/index.html index 3268384..9eb1a7a 100644 --- a/maven-build-cache-extension-multi-module/index.html +++ b/maven-build-cache-extension-multi-module/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -99,7 +100,7 @@

      The short answer

      What we ran

      Three modules: core has no dependencies, util uses core, and app uses util. Each has six small Java classes. Every build used a fresh copy of the project and an empty local build cache, so the only shared thing was the cache server. All six builds ended with BUILD SUCCESS.

      - +
      @@ -109,7 +110,7 @@

      What we ran

      -
      Buildcoreutilapp
      1. First build, as CI would do itbuiltbuiltbuilt
      5. Edit only apprestoredrestoredbuilt
      6. Edit only utilrestoredbuiltbuilt
      +

      "Restored" is the log line Found cached build, restoring demo:core from cache for that module. "Built" means Maven compiled it, then saved the result.

      What this means for your project

      @@ -122,7 +123,7 @@

      What this means for your project

      Set it up once, in the parent

      Two files in the .mvn folder at the root of the project, the same as for a single module:

      -
      <!-- .mvn/extensions.xml -->
      +
      <!-- .mvn/extensions.xml -->
       <extensions>
         <extension>
           <groupId>org.apache.maven.extensions</groupId>
      @@ -130,7 +131,7 @@ 

      Set it up once, in the parent

      <version>1.2.3</version> </extension> </extensions>
      -
      <!-- .mvn/maven-build-cache-config.xml -->
      +
      <!-- .mvn/maven-build-cache-config.xml -->
       <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">
      diff --git a/maven-build-cache-extension-not-restoring/index.html b/maven-build-cache-extension-not-restoring/index.html
      index c1f8de3..7e2d1c4 100644
      --- a/maven-build-cache-extension-not-restoring/index.html
      +++ b/maven-build-cache-extension-not-restoring/index.html
      @@ -69,15 +69,15 @@
               overflow-x: auto; font-size: .9rem; line-height: 1.5; }
         .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
         .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
      -         text-decoration: none; background: var(--accent); color: #fff; }
      -  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
      +         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
      +  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
         .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
         footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
         footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
         a { color: var(--accent); }
      -  table { display: block; max-width: 100%; overflow-x: auto; }
         code { overflow-wrap: anywhere; }
         pre code { overflow-wrap: normal; }
      +  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
       
       
       
      @@ -91,19 +91,19 @@ 

      Maven build cache extension not restoring: the causes

      First, what a healthy build looks like

      The first build stores its result, and it looks alarming because a miss is logged as an error:

      -
      [INFO] Attempting to restore project demo:demo from build cache
      +  
      [INFO] Attempting to restore project demo:demo from build cache
       [ERROR] Error downloading cache item: https://cache.example.com//v1.1/demo/demo/ebb9bceba2f7a6bc/buildinfo.xml
       [INFO] Saved to remote cache https://cache.example.com//v1.1/demo/demo/ebb9bceba2f7a6bc/demo.jar
       [INFO] Saved to remote cache https://cache.example.com//v1.1/demo/demo/ebb9bceba2f7a6bc/buildinfo.xml
       [INFO] BUILD SUCCESS

      That [ERROR] line is only the server saying it does not have the entry yet. What matters is the Saved to remote cache lines after it. After deleting target and the extension's local cache folder, the second build:

      -
      [INFO] Attempting to restore project demo:demo from build cache
      +  
      [INFO] Attempting to restore project demo:demo from build cache
       [INFO] Found cached build, restoring demo:demo from cache by checksum ebb9bceba2f7a6bc
       [INFO] BUILD SUCCESS

      You must delete the local folder to prove the remote works, because the extension keeps its own local cache and will use it first.

      Symptom 1: Maven stops with "Unrecognised tag: 'remote'"

      -
      Cannot initialize cache because xml config is not valid or not available
      +  
      Cannot initialize cache because xml config is not valid or not available
       Unable to parse cache xml element: Unrecognised tag: 'remote' ...

      Cause: the remote element is outside configuration. Fix: move it inside, as in the working file below. Nothing built, and the server saw nothing.

      @@ -112,7 +112,7 @@

      Symptom 2: the build succeeds, but the server stays empty

      Cause: without saveToRemote="true" the extension reads from the server and never uploads. Fix: add it on the machines that should upload, usually CI.

      Symptom 3: "HTTP Status: 401" in the log, and still no uploads

      -
      org.eclipse.aether.spi.connector.transport.http.HttpTransporterException: HTTP Status: 401
      +
      org.eclipse.aether.spi.connector.transport.http.HttpTransporterException: HTTP Status: 401

      The build still ends in BUILD SUCCESS, the server stays empty, and the lines Saved to remote cache are missing. I got exactly this from two different mistakes:

      • the id of the <server> in settings.xml did not match the id on the remote element, and
      • @@ -121,7 +121,7 @@

        Symptom 3: "HTTP Status: 401" in the log, and still no uploads

        Fix: make the two ids identical and check the password. Maven 3.10 may also print a warning about the credentials being used for an address without a matching repository entry. In my working setup the build behaved the same either way.

        The working configuration

        -
        <?xml version="1.0" encoding="UTF-8"?>
        +  
        <?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">
        diff --git a/maven-remote-build-cache-setup/index.html b/maven-remote-build-cache-setup/index.html
        index 637ae83..4a59896 100644
        --- a/maven-remote-build-cache-setup/index.html
        +++ b/maven-remote-build-cache-setup/index.html
        @@ -69,15 +69,15 @@
                 overflow-x: auto; font-size: .9rem; line-height: 1.5; }
           .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
           .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
        -         text-decoration: none; background: var(--accent); color: #fff; }
        -  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
        +         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
        +  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
           .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
           footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
           footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
           a { color: var(--accent); }
        -  table { display: block; max-width: 100%; overflow-x: auto; }
           code { overflow-wrap: anywhere; }
           pre code { overflow-wrap: normal; }
        +  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
         
         
         
        @@ -94,7 +94,7 @@ 

        Who owns what

        1. Add the extension

        In .mvn/extensions.xml (create it if it is missing):

        -
        <extensions>
        +  
        <extensions>
           <extension>
             <groupId>org.apache.maven.extensions</groupId>
             <artifactId>maven-build-cache-extension</artifactId>
        @@ -109,7 +109,7 @@ 

        2. Point it at the server, and switch uploads on

      • The remote element goes inside configuration. Beside it, the extension fails to start with "Unrecognised tag: 'remote'".
      • Add saveToRemote="true" on the machines that should upload. Without it the extension reads from the server but never writes to it, and the server stays empty.
      -
      <?xml version="1.0" encoding="UTF-8"?>
      +  
      <?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">
      @@ -126,7 +126,7 @@ 

      2. Point it at the server, and switch uploads on

      3. Add the password, if the server has one

      In ~/.m2/settings.xml, or your CI system's equivalent. The id must match the id in step 2:

      -
      <settings>
      +  
      <settings>
         <servers>
           <server>
             <id>fosterstack-cache</id>
      @@ -139,7 +139,7 @@ 

      3. Add the password, if the server has one

      4. Build, then build again

      This is a real run: Maven 3.10.0, extension 1.2.3, and a 0.2.1 server, on a one-file project. First build:

      -
      mvn package
      +  
      mvn package
       
       [INFO] Attempting to restore project demo:demo from build cache
       [ERROR] Error downloading cache item: https://cache.example.com//v1.1/demo/demo/ebb9bceba2f7a6bc/buildinfo.xml
      @@ -149,12 +149,12 @@ 

      4. Build, then build again

      [INFO] BUILD SUCCESS [INFO] Saved to remote cache https://cache.example.com//v1.1/demo/demo/9d9dcaf9-3b6b-40fb-9cb5-da97c975eab5/build-cache-report.xml

      The line starting [ERROR] Error downloading cache item is the normal first-time miss, the server saying it does not have that entry yet. The build then ran for real and saved its result to the server. The server's status page showed it:

      -
      $ curl -s -u maven:demo-pass localhost:8080/statusz | grep -E '"store_entries"|"cache_hits"|"cache_misses"'
      +  
      $ curl -s -u maven:demo-pass localhost:8080/statusz | grep -E '"store_entries"|"cache_hits"|"cache_misses"'
         "store_entries": 3,
         "cache_hits": 0,
         "cache_misses": 3,

      Now delete target and the extension's local cache folder, so the only copy left is on the server, and build again:

      -
      rm -rf target build-cache   # 'build-cache' is the extension's local folder, next to your local repository
      +  
      rm -rf target build-cache   # 'build-cache' is the extension's local folder, next to your local repository
       mvn package
       
       [INFO] Attempting to restore project demo:demo from build cache
      diff --git a/migrate-build-cache-node/index.html b/migrate-build-cache-node/index.html
      index ab07c7c..e849948 100644
      --- a/migrate-build-cache-node/index.html
      +++ b/migrate-build-cache-node/index.html
      @@ -69,15 +69,15 @@
               overflow-x: auto; font-size: .9rem; line-height: 1.5; }
         .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
         .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
      -         text-decoration: none; background: var(--accent); color: #fff; }
      -  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
      +         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
      +  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
         .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
         footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
         footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
         a { color: var(--accent); }
      -  table { display: block; max-width: 100%; overflow-x: auto; }
         code { overflow-wrap: anywhere; }
         pre code { overflow-wrap: normal; }
      +  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
       
       
       
      @@ -93,15 +93,15 @@ 

      Before you start

      A build cache holds copies of things your builds can always rebuild, so there is nothing to export from the old node. Your first build on the new server just fills it. If you have not read why the node is going away, see Build Cache Node end of life.

      Step 1: Run the new cache server

      -
      docker run -d -p 127.0.0.1:8080:8080 -v fscache-data:/home/nonroot ghcr.io/fosterstack/cache:latest
      +
      docker run -d -p 127.0.0.1:8080:8080 -v fscache-data:/home/nonroot ghcr.io/fosterstack/cache:latest

      For anything beyond a first look, pin a specific version instead of latest. This command listens on this machine only. For other machines to use it, put an HTTPS proxy in front. Other ways to run it: bare binary with systemd, Kubernetes, or a disconnected network.

      If the server can be reached by anyone you do not trust, turn on Basic Auth. Anyone who can write to the cache can change what your later builds use.

      Step 2: Gradle build cache setup

      Turn the cache on, in gradle.properties:

      -
      org.gradle.caching=true
      +
      org.gradle.caching=true

      Then point Gradle at the new server in settings.gradle.kts. If you used the old node, you only change the URL:

      -
      // FosterStack Cache — remote Gradle build cache.
      +  
      // FosterStack Cache — remote Gradle build cache.
       // https://github.com/fosterstack/cache
       buildCache {
           remote<HttpBuildCache> {
      @@ -114,7 +114,7 @@ 

      Step 2: Gradle build cache setup

      } }

      Or in settings.gradle (Groovy):

      -
      // FosterStack Cache — remote Gradle build cache.
      +  
      // FosterStack Cache — remote Gradle build cache.
       // https://github.com/fosterstack/cache
       buildCache {
           remote(HttpBuildCache) {
      @@ -131,7 +131,7 @@ 

      Step 2: Gradle build cache setup

      Step 3: Maven build cache setup

      Maven uses Apache's Build Cache Extension. FosterStack Cache implements the server side of it.

      Add the extension in .mvn/extensions.xml (check Maven Central for the current version):

      -
      <extensions>
      +  
      <extensions>
         <extension>
           <groupId>org.apache.maven.extensions</groupId>
           <artifactId>maven-build-cache-extension</artifactId>
      @@ -139,7 +139,7 @@ 

      Step 3: Maven build cache setup

      </extension> </extensions>

      Point it at the server in .mvn/maven-build-cache-config.xml:

      -
      <?xml version="1.0" encoding="UTF-8"?>
      +  
      <?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">
      @@ -157,13 +157,13 @@ 

      Step 3: Maven build cache setup

      Step 4: Check that it works

      For Gradle, run the build twice:

      -
      ./gradlew build --build-cache
      +
      ./gradlew build --build-cache

      On the second run, look for FROM-CACHE next to tasks. Gradle also has a local cache that gives hits even when the remote is broken, so to test the remote itself, clear the local one and run with -i:

      -
      rm -rf ~/.gradle/caches/build-cache-1
      +  
      rm -rf ~/.gradle/caches/build-cache-1
       ./gradlew --stop
       ./gradlew build --build-cache -i

      On the server, the hit and miss counters should move:

      -
      curl -s https://cache.example.com/metrics | grep -E 'fscache_cache_(hits|misses)_total'
      +
      curl -s https://cache.example.com/metrics | grep -E 'fscache_cache_(hits|misses)_total'

      For Maven, build twice from a clean checkout and look for cache hits in the log for unchanged modules.

      Step 5: Retire the old node

      diff --git a/migrate-maven-build-cache-to-a-new-server/index.html b/migrate-maven-build-cache-to-a-new-server/index.html index 6482cd7..9a0972c 100644 --- a/migrate-maven-build-cache-to-a-new-server/index.html +++ b/migrate-maven-build-cache-to-a-new-server/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -98,7 +99,7 @@

      The short answer

      What we ran

      A small Maven project, Maven 3.10.0, extension 1.2.3, macOS (arm64). Every build used a fresh copy of the project and an empty local build cache, so the only thing shared was the cache server. All five builds ended with BUILD SUCCESS.

      - +
      @@ -107,25 +108,25 @@

      What we ran

      -
      BuildServer it usedResult
      1Old server, emptyBuilt the project and saved the result to the server
      4New server, from another machineRestored from the cache, no compile
      5New server started on a copy of the old data folderRestored from the cache, no compile
      +

      Option 1: change the address only

      Start the new server, then edit the url in the cache config and commit it. We changed this one line and nothing else:

      -
      <remote enabled="true" saveToRemote="true" id="fosterstack-cache">
      +
      <remote enabled="true" saveToRemote="true" id="fosterstack-cache">
         <url>http://127.0.0.1:18151/</url>
       </remote>

      Build 3 had no cached result to find, so Maven compiled the project and saved it:

      -
      [INFO] Attempting to restore project demo:demo from build cache
      +
      [INFO] Attempting to restore project demo:demo from build cache
       [INFO] Compiling 1 source file with javac [debug release 17] to target/classes
       [INFO] Saved to remote cache http://127.0.0.1:18151//v1.1/demo/demo/ebb9bceba2f7a6bc/demo.jar

      From then on, every other machine pointing at the new address gets the result from the new server, as build 4 did. Anyone who still has the old address in their config keeps using the old server until they update.

      Option 2: copy the data folder first

      This keeps the new server warm from the first build. Stop the old server, copy its data folder, and start the new server with its data folder set to the copy. See back up and restore for why the old server should be stopped first.

      -
      cp -R data-old data-new-copied
      +
      cp -R data-old data-new-copied
       FSCACHE_ADDR=127.0.0.1:18152 FSCACHE_DATA_DIR=$PWD/data-new-copied fscache

      Build 5 found the result straight away, with no compile:

      -
      [INFO] Attempting to restore project demo:demo from build cache
      +
      [INFO] Attempting to restore project demo:demo from build cache
       [INFO] Found cached build, restoring demo:demo from cache by checksum ebb9bceba2f7a6bc

      The new server's status page agreed: 2 hits and 0 misses at that point.

      diff --git a/monitor-build-cache-prometheus-grafana/index.html b/monitor-build-cache-prometheus-grafana/index.html index 4ff1a55..d8092ce 100644 --- a/monitor-build-cache-prometheus-grafana/index.html +++ b/monitor-build-cache-prometheus-grafana/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -93,7 +94,7 @@

      Metrics need no password

      The /metrics and /healthz paths stay open even when Basic Auth is on, so a Prometheus scraper needs no credentials. The status page, /statusz, sits behind Basic Auth when it is enabled.

      A scrape setting

      -
      scrape_configs:
      +  
      scrape_configs:
         - job_name: fscache
           static_configs:
             - targets: ['cache.example.com:8080']
      @@ -101,7 +102,7 @@

      A scrape setting

      The metrics

      These are the names a running 0.2.1 server reports:

      - +
      @@ -112,10 +113,10 @@

      The metrics

      -
      MetricTypeWhat it tells you
      fscache_cache_hits_totalcounterRequests answered from the cache
      fscache_cache_misses_totalcounterRequests for something not stored
      fscache_http_request_duration_secondshistogramHow long requests take
      fscache_bytes_read_totalcounterBytes sent to clients
      fscache_bytes_written_totalcounterBytes received from clients
      +

      Queries worth having

      -
      # hit rate over the last 5 minutes
      +  
      # hit rate over the last 5 minutes
       sum(rate(fscache_cache_hits_total[5m]))
         / clamp_min(sum(rate(fscache_cache_hits_total[5m])) + sum(rate(fscache_cache_misses_total[5m])), 0.0001)
       
      @@ -131,7 +132,7 @@ 

      A ready-made Grafana dashboard

      The status page

      For a quick look without Prometheus, /statusz returns the same numbers in one place, in a browser or as JSON. This is what a freshly started 0.2.1 server returned after one stored entry and two requests:

      -
      $ curl -s localhost:8080/statusz
      +  
      $ curl -s localhost:8080/statusz
       {
         "version": "v0.2.1",
         "revision": "0bfc498ecfca3e38cb4a456acda44359a1620c5a",
      diff --git a/one-build-cache-server-gradle-and-maven/index.html b/one-build-cache-server-gradle-and-maven/index.html
      index b36239e..7eb2fbc 100644
      --- a/one-build-cache-server-gradle-and-maven/index.html
      +++ b/one-build-cache-server-gradle-and-maven/index.html
      @@ -69,15 +69,16 @@
               overflow-x: auto; font-size: .9rem; line-height: 1.5; }
         .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
         .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
      -         text-decoration: none; background: var(--accent); color: #fff; }
      -  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
      +         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
      +  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
         .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
         footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
         footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
         a { color: var(--accent); }
      -  table { display: block; max-width: 100%; overflow-x: auto; }
         code { overflow-wrap: anywhere; }
         pre code { overflow-wrap: normal; }
      +  .table-scroll { max-width: 100%; overflow-x: auto; }
      +  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
       
       
       
      @@ -90,18 +91,18 @@ 

      One build cache server for Gradle and Maven

      If your company builds some projects with Gradle and others with Maven, you do not need two cache servers. FosterStack Cache serves both, with one login. This is a real run: a repository with a Gradle app and a Maven app, both pointed at one 0.2.1 server. Maven works through the Apache Maven Build Cache Extension.

      The repository

      -
      mixed-repo/
      +  
      mixed-repo/
         gradle-app/    settings.gradle.kts, build.gradle.kts, gradle.properties, src/
         maven-app/     pom.xml, settings.xml, src/, .mvn/extensions.xml, .mvn/maven-build-cache-config.xml

      Two small Java apps, built by two different tools.

      One server, one login

      -
      FSCACHE_USERNAME=build FSCACHE_PASSWORD=<a long secret> ./fscache
      +
      FSCACHE_USERNAME=build FSCACHE_PASSWORD=<a long secret> ./fscache

      Both tools send that same login, each in its own way. The secret comes from one environment variable, FSCACHE_PASSWORD.

      The Gradle side

      gradle-app/settings.gradle.kts, with org.gradle.caching=true in gradle.properties:

      -
      rootProject.name = "gradle-app"
      +  
      rootProject.name = "gradle-app"
       
       // FosterStack Cache — remote Gradle build cache.
       // https://github.com/fosterstack/cache
      @@ -118,7 +119,7 @@ 

      The Gradle side

      The Maven side

      maven-app/.mvn/maven-build-cache-config.xml. The remote element goes inside configuration, and saveToRemote="true" is what makes it upload:

      -
      <?xml version="1.0" encoding="UTF-8"?>
      +  
      <?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">
      @@ -132,7 +133,7 @@ 

      The Maven side

      </configuration> </cache>

      And settings.xml, whose server id must match the remote's. Maven filled in the password from the same environment variable:

      -
      <settings>
      +  
      <settings>
         <servers>
           <server>
             <id>fosterstack-cache</id>
      @@ -145,15 +146,15 @@ 

      The Maven side

      What happened

      Each app was built twice. For Gradle, I ran gradle clean in between. For Maven, I deleted target and the extension's local cache folder, so the only copy left was on the server. (I also turned Gradle's local cache off.)

      - +
      -
      BuildResultServer afterwards
      Gradle, first:compileJava ran1 entry
      Gradle, second> Task :compileJava FROM-CACHE, "1 from cache"1 hit
      Maven, firstbuilt, then three lines of Saved to remote cache4 entries in all
      Maven, secondFound cached build, restoring demo:maven-app from cache3 hits in all
      +

      The counts are the server's own, from its status page. Gradle stores its work under keys of a single long hex number. The Maven extension stores its files under a path. Here is what the server held on disk afterwards:

      -
      blobs/8a/8a2fb682ab19c1d434fd2d1ee3e8b27a                              (Gradle)
      +  
      blobs/8a/8a2fb682ab19c1d434fd2d1ee3e8b27a                              (Gradle)
       blobs/v1.1/demo/maven-app/3aa3eaca44f8d0fa/ma/maven-app.jar            (Maven)
       blobs/v1.1/demo/maven-app/3aa3eaca44f8d0fa/bu/buildinfo.xml            (Maven)
       blobs/v1.1/demo/maven-app/e17172d0-.../bu/build-cache-report.xml       (Maven)
      diff --git a/regulated/index.html b/regulated/index.html
      index 778e00a..5aab999 100644
      --- a/regulated/index.html
      +++ b/regulated/index.html
      @@ -67,15 +67,15 @@
               overflow-x: auto; font-size: .9rem; line-height: 1.5; }
         .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
         .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
      -         text-decoration: none; background: var(--accent); color: #fff; }
      -  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
      +         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
      +  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
         .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
         footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
         footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
         a { color: var(--accent); }
      -  table { display: block; max-width: 100%; overflow-x: auto; }
         code { overflow-wrap: anywhere; }
         pre code { overflow-wrap: normal; }
      +  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
       
       
       
      diff --git a/remote-build-cache-security/index.html b/remote-build-cache-security/index.html
      index 1f7ac23..548c6e0 100644
      --- a/remote-build-cache-security/index.html
      +++ b/remote-build-cache-security/index.html
      @@ -69,15 +69,15 @@
               overflow-x: auto; font-size: .9rem; line-height: 1.5; }
         .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
         .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
      -         text-decoration: none; background: var(--accent); color: #fff; }
      -  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
      +         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
      +  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
         .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
         footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
         footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
         a { color: var(--accent); }
      -  table { display: block; max-width: 100%; overflow-x: auto; }
         code { overflow-wrap: anywhere; }
         pre code { overflow-wrap: normal; }
      +  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
       
       
       
      diff --git a/reset-gradle-build-cache/index.html b/reset-gradle-build-cache/index.html
      index 76a20b6..c497c27 100644
      --- a/reset-gradle-build-cache/index.html
      +++ b/reset-gradle-build-cache/index.html
      @@ -69,15 +69,15 @@
               overflow-x: auto; font-size: .9rem; line-height: 1.5; }
         .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
         .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
      -         text-decoration: none; background: var(--accent); color: #fff; }
      -  .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
      +         text-decoration: none; background: var(--accent); color: var(--accent-fg); }
      +  .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); }
         .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
         footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
         footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
         a { color: var(--accent); }
      -  table { display: block; max-width: 100%; overflow-x: auto; }
         code { overflow-wrap: anywhere; }
         pre code { overflow-wrap: normal; }
      +  :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
       
       
       
      @@ -94,15 +94,15 @@ 

      Why there is no purge button

      It is also safe to do. A build cache holds results your builds can always rebuild. The first builds after a reset are slower while it refills, and that is all.

      Docker Compose

      -
      docker compose down -v && docker compose up -d
      +
      docker compose down -v && docker compose up -d

      The -v removes the named data volume. The fresh start sets up its ownership correctly.

      Plain Docker

      -
      docker rm -f fscache
      +  
      docker rm -f fscache
       docker volume rm fscache-data
       # then run your original docker run command again

      Tested on the 0.2.1 image. Before the reset, /statusz showed one stored entry. After removing the container and the volume and starting again:

      -
      $ curl -s localhost:8080/statusz | grep -E '"store_entries"|"store_bytes"'
      +  
      $ curl -s localhost:8080/statusz | grep -E '"store_entries"|"store_bytes"'
         "store_bytes": 0,
         "store_entries": 0,
       $ curl -s localhost:8080/testkey -o /dev/null -w 'GET testkey %{http_code}\n'
      @@ -110,7 +110,7 @@ 

      Plain Docker

      Kubernetes

      Delete the volume claim, then create it again. The Deployment cannot start without its claim, so the new claim is a required step. Tested on a throwaway cluster:

      -
      kubectl -n fscache-ns scale deploy/fscache --replicas=0
      +  
      kubectl -n fscache-ns scale deploy/fscache --replicas=0
       kubectl -n fscache-ns delete pvc fscache-data
       kubectl -n fscache-ns apply -f fscache-pvc.yaml   # your claim manifest, the same one you first applied
       kubectl -n fscache-ns scale deploy/fscache --replicas=1
      diff --git a/security/index.html b/security/index.html index b46db28..1e91260 100644 --- a/security/index.html +++ b/security/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } diff --git a/upgrade-build-cache-server/index.html b/upgrade-build-cache-server/index.html index 81e0cc3..2a3ab19 100644 --- a/upgrade-build-cache-server/index.html +++ b/upgrade-build-cache-server/index.html @@ -69,15 +69,16 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + .table-scroll { max-width: 100%; overflow-x: auto; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -96,7 +97,7 @@

      What was tested, and what was not

    The upgrade

    -
    docker pull ghcr.io/fosterstack/cache:0.2.1
    +  
    docker pull ghcr.io/fosterstack/cache:0.2.1
     docker stop fscache && docker rm fscache
     docker run -d --name fscache -p 127.0.0.1:8080:8080 \
       -v fscache-data:/home/nonroot \
    @@ -104,13 +105,13 @@ 

    The upgrade

    Use the same volume and the same settings (environment variables for the password and size cap) as the old container. Pin the exact tag you want, as above, rather than latest.

    What I saw

    - +
    -
    StepResult
    0.1.0: store three entries on a fresh volume201 each; its metrics show 3 entries, 29 bytes
    Stop 0.1.0, start 0.2.1 on the same volumestarts normally; the status page shows "version": "v0.2.1", 3 entries, 29 bytes; all three values read back
    0.2.1: store two more201 each; 5 entries
    Stop 0.2.1, start 0.1.0 on the same volume againstarts normally; all values read back, including the two written by 0.2.1
    +

    So in my test an upgrade lost nothing, and a rollback to the old release could still read what the new one had written.

    Two differences between the releases

    diff --git a/verify-a-release/index.html b/verify-a-release/index.html index 577665e..c313844 100644 --- a/verify-a-release/index.html +++ b/verify-a-release/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -91,11 +91,11 @@

    Verify a FosterStack Cache release yourself

    What you need

    cosign, the gh tool (for the build-provenance steps), and jq. Set the version and your platform once:

    -
    $ VER=0.2.1
    +  
    $ VER=0.2.1
     PLATFORM=linux_arm64   # or linux_amd64, darwin_arm64, darwin_amd64

    1. Download the release files

    -
    $ gh release download v${VER} --repo fosterstack/cache \
    +  
    $ gh release download v${VER} --repo fosterstack/cache \
       -p checksums.txt -p checksums.txt.bundle \
       -p "fscache_${VER}_${PLATFORM}.tar.gz" \
       -p "fscache_${VER}_${PLATFORM}.tar.gz.sbom.json" \
    @@ -104,7 +104,7 @@ 

    1. Download the release files

    2. Is the checksums file ours?

    This checks the signature on checksums.txt. It needs no GitHub account.

    -
    $ cosign verify-blob \
    +  
    $ cosign verify-blob \
       --bundle checksums.txt.bundle \
       --certificate-identity-regexp='^https://github.com/fosterstack/cache/' \
       --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \
    @@ -112,13 +112,13 @@ 

    2. Is the checksums file ours?

    Verified OK

    3. Does your download match it?

    -
    $ sha256sum -c <(grep "fscache_${VER}_${PLATFORM}.tar.gz" checksums.txt | grep -v sbom)
    +  
    $ sha256sum -c <(grep "fscache_${VER}_${PLATFORM}.tar.gz" checksums.txt | grep -v sbom)
     fscache_0.2.1_linux_arm64.tar.gz: OK

    Steps 2 and 3 together are the important pair. The checksum alone only shows the archive matches a file you downloaded beside it. The signature is what ties that file to us.

    4. Is the container image signed by our release workflow?

    Pin the workflow and the release tag, not "any workflow in the repository". No account is needed.

    -
    $ cosign verify ghcr.io/fosterstack/cache:${VER} \
    +  
    $ cosign verify ghcr.io/fosterstack/cache:${VER} \
       --certificate-identity-regexp="^https://github.com/fosterstack/cache/.github/workflows/stage-promote.yml@refs/tags/v${VER}$" \
       --certificate-oidc-issuer='https://token.actions.githubusercontent.com'
     Verification for ghcr.io/fosterstack/cache:0.2.1 --
    @@ -132,7 +132,7 @@ 

    4. Is the container image signed by our release workflow?

    5. Where was the image built?

    A signature says who signed. Build provenance says what produced the bytes. This step reads GitHub's attestation store, so it needs a token (GH_TOKEN or gh auth login), even though the repository is public.

    -
    $ gh attestation verify oci://ghcr.io/fosterstack/cache:${VER} \
    +  
    $ gh attestation verify oci://ghcr.io/fosterstack/cache:${VER} \
       --repo fosterstack/cache \
       --signer-workflow fosterstack/cache/.github/workflows/stage-image.yml \
       --source-ref "refs/tags/v${VER}" \
    @@ -148,7 +148,7 @@ 

    5. Where was the image built?

    The command exits with status 0 on success. The image digest is the one from step 4, and the run link is the public build that produced it.

    6. Where was your download built?

    -
    $ gh attestation verify "fscache_${VER}_${PLATFORM}.tar.gz" --repo fosterstack/cache \
    +  
    $ gh attestation verify "fscache_${VER}_${PLATFORM}.tar.gz" --repo fosterstack/cache \
       --format json | jq -r '.[0].verificationResult.statement.subject[].name, .[0].verificationResult.signature.certificate.subjectAlternativeName'
     fscache-fips_0.2.1_linux_amd64.tar.gz
     fscache-fips_0.2.1_linux_amd64.tar.gz.sbom.json
    @@ -168,7 +168,7 @@ 

    6. Where was your download built?

    7. Did the whole release chain pass?

    Each stage of the release signs its own statement about the exact image. One of them exists only if the final authorization stage checked all the others for this image: the source check, the build, the image assembly, the reproducibility check, each scanner's verdict, and the acceptance tests.

    -
    $ gh attestation verify oci://ghcr.io/fosterstack/cache:${VER} \
    +  
    $ gh attestation verify oci://ghcr.io/fosterstack/cache:${VER} \
       --repo fosterstack/cache \
       --signer-workflow fosterstack/cache/.github/workflows/stage-authorize.yml \
       --predicate-type https://fosterstack.com/attestations/release-authorization/v1 \
    @@ -180,13 +180,13 @@ 

    7. Did the whole release chain pass?

    8. What is inside? The SBOM

    Each binary archive has a software bill of materials in SPDX format.

    -
    $ jq '{spdxVersion, name, packages: (.packages | length)}' "fscache_${VER}_${PLATFORM}.tar.gz.sbom.json"
    +  
    $ jq '{spdxVersion, name, packages: (.packages | length)}' "fscache_${VER}_${PLATFORM}.tar.gz.sbom.json"
     {
       "spdxVersion": "SPDX-2.3",
       "name": "fscache_0.2.1_linux_arm64.tar.gz",
       "packages": 13
     }
    -
    $ jq -r '.packages[0:4][] | "\(.name) \(.versionInfo)"' "fscache_${VER}_${PLATFORM}.tar.gz.sbom.json"
    +  
    $ jq -r '.packages[0:4][] | "\(.name) \(.versionInfo)"' "fscache_${VER}_${PLATFORM}.tar.gz.sbom.json"
     github.com/beorn7/perks v1.0.1
     github.com/cespare/xxhash/v2 v2.3.0
     github.com/fosterstack/cache v0.2.1
    @@ -194,7 +194,7 @@ 

    8. What is inside? The SBOM

    9. Which known vulnerabilities do not apply? The VEX file

    The VEX (OpenVEX) file lists vulnerabilities that scanners may report and says why each does not affect this release.

    -
    $ jq -r '.statements[] | "\(.vulnerability.name) \(.status) \(.justification)"' fosterstack-cache.openvex.json
    +  
    $ jq -r '.statements[] | "\(.vulnerability.name) \(.status) \(.justification)"' fosterstack-cache.openvex.json
     CVE-2024-51744 not_affected component_not_present
     CVE-2025-60876 not_affected vulnerable_code_not_in_execute_path
     CVE-2025-46394 not_affected vulnerable_code_not_in_execute_path
    @@ -203,13 +203,13 @@ 

    9. Which known vulnerabilities do not apply? The VEX file

    10. The release manifest

    release-manifest.json lists the evidence for the whole release. It names three image variants (the standard one, debug and fips), each with its digest. The standard one is the digest you verified in steps 4 and 5, at both registries:

    -
    $ jq -r '.images[] | "\(.variant) \(.digest)"' release-manifest.json
    +  
    $ jq -r '.images[] | "\(.variant) \(.digest)"' release-manifest.json
     production sha256:8df991b5febdf5b4a177325b8af95b6bb6c321059e1715c06cb659c659f5a4f0
     debug sha256:e2e502f44d8bd9e6967cb160aabeb012e8520ebba0faa88e130143fe534f282e
     fips sha256:e125fe3f6ed2c18c478453300ba2b787a42999edf66a39403877efb39865ab1b
    -
    $ jq -c '.images[0].refs' release-manifest.json
    +  
    $ jq -c '.images[0].refs' release-manifest.json
     ["ghcr.io/fosterstack/cache@sha256:8df991b5febdf5b4a177325b8af95b6bb6c321059e1715c06cb659c659f5a4f0","docker.io/fosterstack/cache@sha256:8df991b5febdf5b4a177325b8af95b6bb6c321059e1715c06cb659c659f5a4f0"]
    -
    $ jq '.verified_statements | length' release-manifest.json
    +  
    $ jq '.verified_statements | length' release-manifest.json
     43

    That last number is the statements the authorization stage checked, each with its entry in the public transparency log.

    diff --git a/verify-container-image-signature-cosign/index.html b/verify-container-image-signature-cosign/index.html index b728a22..13c6618 100644 --- a/verify-container-image-signature-cosign/index.html +++ b/verify-container-image-signature-cosign/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @@ -90,19 +90,19 @@

    Verify a container image signature with cosign

    How to check that a FosterStack Cache image was signed by our release workflow and built by GitHub's CI, using cosign and the gh tool. It takes under a minute.

    Find the current version

    -
    VER=$(curl -fsSL https://api.github.com/repos/fosterstack/cache/releases/latest \
    +  
    VER=$(curl -fsSL https://api.github.com/repos/fosterstack/cache/releases/latest \
             | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')

    1. Check the signature

    This answers one question: did our release workflow sign these exact bytes? Pin the workflow and the release tag, not "any workflow in the repository".

    -
    cosign verify ghcr.io/fosterstack/cache:${VER} \
    +  
    cosign verify ghcr.io/fosterstack/cache:${VER} \
       --certificate-identity-regexp="^https://github.com/fosterstack/cache/.github/workflows/stage-promote.yml@refs/tags/v${VER}$" \
       --certificate-oidc-issuer='https://token.actions.githubusercontent.com'

    There is no signing key to steal or rotate. The certificate is short-lived and issued at the moment the workflow ran, and the signature is recorded in a public transparency log. No account is needed for this step.

    2. Check where it was built

    This answers a different question: what produced these bytes? It reads a signed statement about the build, naming the repository, workflow and run. It needs a GitHub token (GH_TOKEN or gh auth login), even though the repository is public.

    -
    gh attestation verify oci://ghcr.io/fosterstack/cache:${VER} \
    +  
    gh attestation verify oci://ghcr.io/fosterstack/cache:${VER} \
       --repo fosterstack/cache \
       --signer-workflow fosterstack/cache/.github/workflows/stage-image.yml \
       --source-ref "refs/tags/v${VER}"
    diff --git a/verify-replacement-build-cache/index.html b/verify-replacement-build-cache/index.html index f48a68c..e54cb65 100644 --- a/verify-replacement-build-cache/index.html +++ b/verify-replacement-build-cache/index.html @@ -69,15 +69,15 @@ overflow-x: auto; font-size: .9rem; line-height: 1.5; } .cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; } .btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600; - text-decoration: none; background: var(--accent); color: #fff; } - .btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; } + text-decoration: none; background: var(--accent); color: var(--accent-fg); } + .btn-secondary { background: transparent; color: inherit; border: 1px solid var(--muted); } .muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; } footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; } footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; } a { color: var(--accent); } - table { display: block; max-width: 100%; overflow-x: auto; } code { overflow-wrap: anywhere; } pre code { overflow-wrap: normal; } + :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } From 4f453211cb51125a5e46718f9e0765ae6c60aa7e Mon Sep 17 00:00:00 2001 From: FosterStack Admin <317177128+fosterstack-admin@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:22:26 -0400 Subject: [PATCH 25/25] README: repair a garbled sentence about analytics (found by the independent review) --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 0a87ee0..23a8af3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # fosterstack/www — marketing site Static landing page for fosterstack.com. No forms, no email capture, no cookies, no -analytics, and no third-party requests of any kind -service, no analytics (add privacy-respecting analytics later if wanted). +analytics, and no third-party requests of any kind. **One-time setup after cloning:** `git config core.hooksPath .githooks` — enables the public-repo hygiene pre-commit hook (see below). CI enforces the same check as a backstop